-Change notes from older releases. For current info see RELEASE-NOTES-1.33.
+Change notes from older releases. For current info see RELEASE-NOTES-1.34.
= MediaWiki 1.32 =
THIS IS NOT A RELEASE YET
-MediaWiki 1.33 is an alpha-quality branch and is not recommended for use in
-production.
+MediaWiki 1.33 is a pre-release testing branch, and is not recommended for use
+in production.
== Upgrading notes for 1.33 ==
1.33 has several database changes since 1.32, and will not work without schema
For notes on 1.32.x and older releases, see HISTORY.
=== Configuration changes for system administrators in 1.33 ===
+
==== New configuration ====
* $wgEnablePartialBlocks – This enables the Partial Blocks feature, which gives
accounts with block permissions the ability to block users, IPs, and IP ranges
argon2 to be used, by default, it will automatically choose the best available
algorithm depending on which version of PHP you have available. To use this,
you can set `$wgPasswordDefault = 'argon2';`.
+* $wgActorTableSchemaMigrationStage now defaults to reading the new schema.
+ update.php will back-populate the new database fields due to the changed
+ setting, which may take some time on large wikis. You can avoid downtime by
+ following a process like that described in T188327.
==== Removed configuration ====
* $wgTagStatisticsNewTable (T199334) — This temporary setting, added in
associated with this entry in the patrol log.
=== External library changes in 1.33 ===
+
==== New external libraries ====
* Added wikimedia/password-blacklist 0.1.4.
* Added guzzlehttp/guzzle 6.3.3.
* MessageBlobStore::getBlob(), deprecated in 1.27, has been removed.
Use ::getBlobs() instead.
* The .background-size() LESS mixin, deprecated in 1.27, has been removed.
+* MWNamespace::clearCaches() has been removed. So has the $rebuild parameter
+ to MWNamespace::getCanonicalNamespaces(), which was deprecated since 1.31.
+ Instead, create a new NamespaceInfo, such as by calling
+ resetServiceForTesting( 'NamespaceInfo' ) on a MediaWikiServices.
+ For classes that inherit from MediaWikiTestCase and used setMwGlobals() to
+ modify a variable that affects namespaces, caches will automatically be
+ reset and any calls to MWNamespace::clearCaches() can be removed entirely.
+* ReadOnlyMode::clearCache() and ConfiguredReadOnlyMode::clearCache() have been
+ removed. Use MediaWikiTestCase::overrideMwServices() instead.
=== Deprecations in 1.33 ===
* The configuration option $wgUseESI has been deprecated, and is expected
* Block::isValid is deprecated, since it is no longer needed in core.
* Calling Maintenance::hasArg() as well as Maintenance::getArg() with no
parameter has been deprecated. Please pass the argument number 0.
+* The MWNamespace class is deprecated. Use MediaWikiServices::getNamespaceInfo.
=== Other changes in 1.33 ===
* (T201747) Html::openElement() warns if given an element name with a space
--- /dev/null
+= MediaWiki 1.34 =
+
+== MediaWiki 1.34.0-PRERELEASE ==
+
+THIS IS NOT A RELEASE YET
+
+MediaWiki 1.34 is an alpha-quality development branch, and is not recommended
+for use in production.
+
+== Upgrading notes for 1.34 ==
+1.34 has several database changes since 1.33, and will not work without schema
+updates. Note that due to changes to some very large tables like the revision
+table, the schema update may take quite long (minutes on a medium sized site,
+many hours on a large site).
+
+Don't forget to always back up your database before upgrading!
+
+See the file UPGRADE for more detailed upgrade instructions, including
+important information when upgrading from versions prior to 1.11.
+
+Some specific notes for MediaWiki 1.34 upgrades are below:
+
+* …
+
+For notes on 1.33.x and older releases, see HISTORY.
+
+=== Configuration changes for system administrators in 1.34 ===
+==== New configuration ====
+* …
+
+==== Changed configuration ====
+* …
+
+==== Removed configuration ====
+* …
+
+=== New user-facing features in 1.34 ===
+* …
+
+=== New developer features in 1.34 ===
+* …
+
+=== External library changes in 1.34 ===
+==== New external libraries ====
+* …
+
+==== Changed external libraries ====
+* …
+
+==== Removed external libraries ====
+* …
+
+=== Bug fixes in 1.34 ===
+* …
+
+=== Action API changes in 1.34 ===
+* …
+
+=== Action API internal changes in 1.34 ===
+* …
+
+=== Languages updated in 1.34 ===
+MediaWiki supports over 350 languages. Many localisations are updated regularly.
+Below only new and removed languages are listed, as well as changes to languages
+because of Phabricator reports.
+
+* …
+
+=== Breaking changes in 1.34 ===
+* Preferences class, deprecated in 1.31, has been removed.
+* …
+
+=== Deprecations in 1.34 ===
+* …
+
+=== Other changes in 1.34 ===
+* …
+
+== Compatibility ==
+MediaWiki 1.34 requires PHP 7.0.13 or later. Although HHVM 3.18.5 or later is
+supported, it is generally advised to use PHP 7.0.13 or later for long term
+support.
+
+MySQL/MariaDB is the recommended DBMS. PostgreSQL or SQLite can also be used,
+but support for them is somewhat less mature. There is experimental support for
+Oracle and Microsoft SQL Server.
+
+The supported versions are:
+
+* MySQL 5.5.8 or later
+* PostgreSQL 9.2 or later
+* SQLite 3.8.0 or later
+* Oracle 9.0.1 or later
+* Microsoft SQL Server 2005 (9.00.1399)
+
+== Online documentation ==
+Documentation for both end-users and site administrators is available on
+MediaWiki.org, and is covered under the GNU Free Documentation License (except
+for pages that explicitly state that their contents are in the public domain):
+
+ https://www.mediawiki.org/wiki/Special:MyLanguage/Documentation
+
+== Mailing list ==
+A mailing list is available for MediaWiki user support and discussion:
+
+ https://lists.wikimedia.org/mailman/listinfo/mediawiki-l
+
+A low-traffic announcements-only list is also available:
+
+ https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce
+
+It's highly recommended that you sign up for one of these lists if you're
+going to run a public MediaWiki, so you can be notified of security fixes.
+
+== IRC help ==
+There's usually someone online in #mediawiki on irc.freenode.net.
'DatabaseUpdater' => __DIR__ . '/includes/installer/DatabaseUpdater.php',
'DateFormats' => __DIR__ . '/maintenance/language/date-formats.php',
'DateFormatter' => __DIR__ . '/includes/parser/DateFormatter.php',
+ 'DateFormatterFactory' => __DIR__ . '/includes/parser/DateFormatterFactory.php',
'DeadendPagesPage' => __DIR__ . '/includes/specials/SpecialDeadendpages.php',
'DeduplicateArchiveRevId' => __DIR__ . '/maintenance/deduplicateArchiveRevId.php',
'DeferrableCallback' => __DIR__ . '/includes/deferred/DeferrableCallback.php',
'GenerateNormalizerDataMl' => __DIR__ . '/maintenance/language/generateNormalizerDataMl.php',
'GenerateSitemap' => __DIR__ . '/maintenance/generateSitemap.php',
'GenericArrayObject' => __DIR__ . '/includes/libs/GenericArrayObject.php',
+ 'GenericParameterJob' => __DIR__ . '/includes/jobqueue/GenericParameterJob.php',
'GetConfiguration' => __DIR__ . '/maintenance/getConfiguration.php',
'GetLagTimes' => __DIR__ . '/maintenance/getLagTimes.php',
'GetReplicaServer' => __DIR__ . '/maintenance/getReplicaServer.php',
'NamespaceAwareForeignTitleFactory' => __DIR__ . '/includes/title/NamespaceAwareForeignTitleFactory.php',
'NamespaceDupes' => __DIR__ . '/maintenance/namespaceDupes.php',
'NamespaceImportTitleFactory' => __DIR__ . '/includes/title/NamespaceImportTitleFactory.php',
+ 'NamespaceInfo' => __DIR__ . '/includes/title/NamespaceInfo.php',
'NewFilesPager' => __DIR__ . '/includes/specials/pagers/NewFilesPager.php',
'NewPagesPager' => __DIR__ . '/includes/specials/pagers/NewPagesPager.php',
'NewUsersLogFormatter' => __DIR__ . '/includes/logging/NewUsersLogFormatter.php',
'PostgreSqlLockManager' => __DIR__ . '/includes/libs/lockmanager/PostgreSqlLockManager.php',
'PostgresInstaller' => __DIR__ . '/includes/installer/PostgresInstaller.php',
'PostgresUpdater' => __DIR__ . '/includes/installer/PostgresUpdater.php',
- 'Preferences' => __DIR__ . '/includes/Preferences.php',
'PreferencesForm' => __DIR__ . '/includes/specials/forms/PreferencesFormLegacy.php',
'PreferencesFormLegacy' => __DIR__ . '/includes/specials/forms/PreferencesFormLegacy.php',
'PreferencesFormOOUI' => __DIR__ . '/includes/specials/forms/PreferencesFormOOUI.php',
'RowUpdateGenerator' => __DIR__ . '/includes/utils/RowUpdateGenerator.php',
'RunBatchedQuery' => __DIR__ . '/maintenance/runBatchedQuery.php',
'RunJobs' => __DIR__ . '/maintenance/runJobs.php',
+ 'RunnableJob' => __DIR__ . '/includes/jobqueue/RunnableJob.php',
'SVGMetadataExtractor' => __DIR__ . '/includes/media/SVGMetadataExtractor.php',
'SVGReader' => __DIR__ . '/includes/media/SVGMetadataExtractor.php',
'SamplingStatsdClient' => __DIR__ . '/includes/libs/stats/SamplingStatsdClient.php',
$index: Integer; the index of the namespace being checked.
&$result: Boolean; whether MediaWiki currently thinks that pages in this
namespace are movable. Hooks may change this value to override the return
- value of MWNamespace::isMovable().
+ value of NamespaceInfo::isMovable().
'NewDifferenceEngine': Called when a new DifferenceEngine object is made
$title: the diff page title (nullable)
public function setReason( $msg ) {
$this->overrideReason = $msg;
}
-
- /**
- * Clear the cache of the read only file
- */
- public function clearCache() {
- $this->fileReason = null;
- }
}
* MediaWiki version number
* @since 1.2
*/
-$wgVersion = '1.33.0-alpha';
+$wgVersion = '1.34.0-alpha';
/**
* Name of the site. It must be changed in LocalSettings.php
$wgCapitalLinks = true;
/**
- * @since 1.16 - This can now be set per-namespace. Some special namespaces (such
- * as Special, see MWNamespace::$alwaysCapitalizedNamespaces for the full list) must be
- * true by default (and setting them has no effect), due to various things that
- * require them to be so. Also, since Talk namespaces need to directly mirror their
- * associated content namespaces, the values for those are ignored in favor of the
- * subject namespace's setting. Setting for NS_MEDIA is taken automatically from
- * NS_FILE.
+ * @since 1.16 - This can now be set per-namespace. Some special namespaces (such as Special, see
+ * NamespaceInfo::$alwaysCapitalizedNamespaces for the full list) must be true by default (and
+ * setting them has no effect), due to various things that require them to be so. Also, since Talk
+ * namespaces need to directly mirror their associated content namespaces, the values for those are
+ * ignored in favor of the subject namespace's setting. Setting for NS_MEDIA is taken automatically
+ * from NS_FILE.
*
* @par Example:
* @code
* @since 1.32 changed allowed flags
* @var int An appropriate combination of SCHEMA_COMPAT_XXX flags.
*/
-$wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_OLD;
+$wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW;
/**
* Flag to enable Partial Blocks. This allows an admin to prevent a user from editing specific pages
use MediaWiki\MediaWikiServices;
/**
- * This is a utility class with only static functions
- * for dealing with namespaces that encodes all the
- * "magic" behaviors of them based on index. The textual
- * names of the namespaces are handled by Language.php.
- *
- * These are synonyms for the names given in the language file
- * Users and translators should not change them
+ * @deprecated since 1.33, use NamespaceInfo instead
*/
class MWNamespace {
-
- /**
- * These namespaces should always be first-letter capitalized, now and
- * forevermore. Historically, they could've probably been lowercased too,
- * but some things are just too ingrained now. :)
- */
- private static $alwaysCapitalizedNamespaces = [ NS_SPECIAL, NS_USER, NS_MEDIAWIKI ];
-
- /** @var string[]|null Canonical namespaces cache */
- private static $canonicalNamespaces = null;
-
- /** @var array|false Canonical namespaces index cache */
- private static $namespaceIndexes = false;
-
- /** @var int[]|null Valid namespaces cache */
- private static $validNamespaces = null;
-
- /**
- * Throw an exception when trying to get the subject or talk page
- * for a given namespace where it does not make sense.
- * Special namespaces are defined in includes/Defines.php and have
- * a value below 0 (ex: NS_SPECIAL = -1 , NS_MEDIA = -2)
- *
- * @param int $index
- * @param string $method
- *
- * @throws MWException
- * @return bool
- */
- private static function isMethodValidFor( $index, $method ) {
- if ( $index < NS_MAIN ) {
- throw new MWException( "$method does not make any sense for given namespace $index" );
- }
- return true;
- }
-
- /**
- * Clear internal caches
- *
- * For use in unit testing when namespace configuration is changed.
- *
- * @since 1.31
- */
- public static function clearCaches() {
- self::$canonicalNamespaces = null;
- self::$namespaceIndexes = false;
- self::$validNamespaces = null;
- }
-
/**
* Can pages in the given namespace be moved?
*
* @return bool
*/
public static function isMovable( $index ) {
- global $wgAllowImageMoving;
-
- $result = !( $index < NS_MAIN || ( $index == NS_FILE && !$wgAllowImageMoving ) );
-
- /**
- * @since 1.20
- */
- Hooks::run( 'NamespaceIsMovable', [ $index, &$result ] );
-
- return $result;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->isMovable( $index );
}
/**
* @since 1.19
*/
public static function isSubject( $index ) {
- return !self::isTalk( $index );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->isSubject( $index );
}
/**
* @return bool
*/
public static function isTalk( $index ) {
- return $index > NS_MAIN
- && $index % 2;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->isTalk( $index );
}
/**
* @return int
*/
public static function getTalk( $index ) {
- self::isMethodValidFor( $index, __METHOD__ );
- return self::isTalk( $index )
- ? $index
- : $index + 1;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getTalk( $index );
}
/**
* @return int
*/
public static function getSubject( $index ) {
- # Handle special namespaces
- if ( $index < NS_MAIN ) {
- return $index;
- }
-
- return self::isTalk( $index )
- ? $index - 1
- : $index;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getSubject( $index );
}
/**
* @return int|null If no associated namespace could be found
*/
public static function getAssociated( $index ) {
- self::isMethodValidFor( $index, __METHOD__ );
-
- if ( self::isSubject( $index ) ) {
- return self::getTalk( $index );
- } elseif ( self::isTalk( $index ) ) {
- return self::getSubject( $index );
- } else {
- return null;
- }
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getAssociated( $index );
}
/**
* @since 1.19
*/
public static function exists( $index ) {
- $nslist = self::getCanonicalNamespaces();
- return isset( $nslist[$index] );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->exists( $index );
}
/**
* @since 1.19
*/
public static function equals( $ns1, $ns2 ) {
- return $ns1 == $ns2;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->equals( $ns1, $ns2 );
}
/**
* @since 1.19
*/
public static function subjectEquals( $ns1, $ns2 ) {
- return self::getSubject( $ns1 ) == self::getSubject( $ns2 );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->
+ subjectEquals( $ns1, $ns2 );
}
/**
* Returns array of all defined namespaces with their canonical
* (English) names.
*
- * @param bool $rebuild Rebuild namespace list (default = false). Used for testing.
- * Deprecated since 1.31, use self::clearCaches() instead.
- *
* @return array
* @since 1.17
*/
- public static function getCanonicalNamespaces( $rebuild = false ) {
- if ( $rebuild ) {
- self::clearCaches();
- }
-
- if ( self::$canonicalNamespaces === null ) {
- global $wgExtraNamespaces, $wgCanonicalNamespaceNames;
- self::$canonicalNamespaces = [ NS_MAIN => '' ] + $wgCanonicalNamespaceNames;
- // Add extension namespaces
- self::$canonicalNamespaces +=
- ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' );
- if ( is_array( $wgExtraNamespaces ) ) {
- self::$canonicalNamespaces += $wgExtraNamespaces;
- }
- Hooks::run( 'CanonicalNamespaces', [ &self::$canonicalNamespaces ] );
- }
- return self::$canonicalNamespaces;
+ public static function getCanonicalNamespaces() {
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getCanonicalNamespaces();
}
/**
* @return string|bool If no canonical definition.
*/
public static function getCanonicalName( $index ) {
- $nslist = self::getCanonicalNamespaces();
- return $nslist[$index] ?? false;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getCanonicalName( $index );
}
/**
* @return int
*/
public static function getCanonicalIndex( $name ) {
- if ( self::$namespaceIndexes === false ) {
- self::$namespaceIndexes = [];
- foreach ( self::getCanonicalNamespaces() as $i => $text ) {
- self::$namespaceIndexes[strtolower( $text )] = $i;
- }
- }
- if ( array_key_exists( $name, self::$namespaceIndexes ) ) {
- return self::$namespaceIndexes[$name];
- } else {
- return null;
- }
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getCanonicalIndex( $name );
}
/**
* @return array
*/
public static function getValidNamespaces() {
- if ( is_null( self::$validNamespaces ) ) {
- foreach ( array_keys( self::getCanonicalNamespaces() ) as $ns ) {
- if ( $ns >= 0 ) {
- self::$validNamespaces[] = $ns;
- }
- }
- // T109137: sort numerically
- sort( self::$validNamespaces, SORT_NUMERIC );
- }
-
- return self::$validNamespaces;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getValidNamespaces();
}
/**
* @return bool True if this namespace either is or has a corresponding talk namespace.
*/
public static function hasTalkNamespace( $index ) {
- return $index >= NS_MAIN;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->hasTalkNamespace( $index );
}
/**
* @return bool
*/
public static function isContent( $index ) {
- global $wgContentNamespaces;
- return $index == NS_MAIN || in_array( $index, $wgContentNamespaces );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->isContent( $index );
}
/**
* @return bool
*/
public static function wantSignatures( $index ) {
- global $wgExtraSignatureNamespaces;
- return self::isTalk( $index ) || in_array( $index, $wgExtraSignatureNamespaces );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->wantSignatures( $index );
}
/**
* @return bool
*/
public static function isWatchable( $index ) {
- return $index >= NS_MAIN;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->isWatchable( $index );
}
/**
* @return bool
*/
public static function hasSubpages( $index ) {
- global $wgNamespacesWithSubpages;
- return !empty( $wgNamespacesWithSubpages[$index] );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->hasSubpages( $index );
}
/**
* @return array Array of namespace indices
*/
public static function getContentNamespaces() {
- global $wgContentNamespaces;
- if ( !is_array( $wgContentNamespaces ) || $wgContentNamespaces === [] ) {
- return [ NS_MAIN ];
- } elseif ( !in_array( NS_MAIN, $wgContentNamespaces ) ) {
- // always force NS_MAIN to be part of array (to match the algorithm used by isContent)
- return array_merge( [ NS_MAIN ], $wgContentNamespaces );
- } else {
- return $wgContentNamespaces;
- }
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getContentNamespaces();
}
/**
* @return array Array of namespace indices
*/
public static function getSubjectNamespaces() {
- return array_filter(
- self::getValidNamespaces(),
- 'MWNamespace::isSubject'
- );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getSubjectNamespaces();
}
/**
* @return array Array of namespace indices
*/
public static function getTalkNamespaces() {
- return array_filter(
- self::getValidNamespaces(),
- 'MWNamespace::isTalk'
- );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->getTalkNamespaces();
}
/**
* @return bool
*/
public static function isCapitalized( $index ) {
- global $wgCapitalLinks, $wgCapitalLinkOverrides;
- // Turn NS_MEDIA into NS_FILE
- $index = $index === NS_MEDIA ? NS_FILE : $index;
-
- // Make sure to get the subject of our namespace
- $index = self::getSubject( $index );
-
- // Some namespaces are special and should always be upper case
- if ( in_array( $index, self::$alwaysCapitalizedNamespaces ) ) {
- return true;
- }
- if ( isset( $wgCapitalLinkOverrides[$index] ) ) {
- // $wgCapitalLinkOverrides is explicitly set
- return $wgCapitalLinkOverrides[$index];
- }
- // Default to the global setting
- return $wgCapitalLinks;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->isCapitalized( $index );
}
/**
* @return bool
*/
public static function hasGenderDistinction( $index ) {
- return $index == NS_USER || $index == NS_USER_TALK;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->
+ hasGenderDistinction( $index );
}
/**
* @return bool
*/
public static function isNonincludable( $index ) {
- global $wgNonincludableNamespaces;
- return $wgNonincludableNamespaces && in_array( $index, $wgNonincludableNamespaces );
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->isNonincludable( $index );
}
/**
* @return null|string Default model name for the given namespace, if set
*/
public static function getNamespaceContentModel( $index ) {
- $config = MediaWikiServices::getInstance()->getMainConfig();
- $models = $config->get( 'NamespaceContentModels' );
- return $models[$index] ?? null;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->
+ getNamespaceContentModel( $index );
}
/**
* @return array
*/
public static function getRestrictionLevels( $index, User $user = null ) {
- global $wgNamespaceProtection, $wgRestrictionLevels;
-
- if ( !isset( $wgNamespaceProtection[$index] ) ) {
- // All levels are valid if there's no namespace restriction.
- // But still filter by user, if necessary
- $levels = $wgRestrictionLevels;
- if ( $user ) {
- $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
- $right = $level;
- if ( $right == 'sysop' ) {
- $right = 'editprotected'; // BC
- }
- if ( $right == 'autoconfirmed' ) {
- $right = 'editsemiprotected'; // BC
- }
- return ( $right == '' || $user->isAllowed( $right ) );
- } ) );
- }
- return $levels;
- }
-
- // First, get the list of groups that can edit this namespace.
- $namespaceGroups = [];
- $combine = 'array_merge';
- foreach ( (array)$wgNamespaceProtection[$index] as $right ) {
- if ( $right == 'sysop' ) {
- $right = 'editprotected'; // BC
- }
- if ( $right == 'autoconfirmed' ) {
- $right = 'editsemiprotected'; // BC
- }
- if ( $right != '' ) {
- $namespaceGroups = call_user_func( $combine, $namespaceGroups,
- User::getGroupsWithPermission( $right ) );
- $combine = 'array_intersect';
- }
- }
-
- // Now, keep only those restriction levels where there is at least one
- // group that can edit the namespace but would be blocked by the
- // restriction.
- $usableLevels = [ '' ];
- foreach ( $wgRestrictionLevels as $level ) {
- $right = $level;
- if ( $right == 'sysop' ) {
- $right = 'editprotected'; // BC
- }
- if ( $right == 'autoconfirmed' ) {
- $right = 'editsemiprotected'; // BC
- }
- if ( $right != '' && ( !$user || $user->isAllowed( $right ) ) &&
- array_diff( $namespaceGroups, User::getGroupsWithPermission( $right ) )
- ) {
- $usableLevels[] = $level;
- }
- }
-
- return $usableLevels;
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->
+ getRestrictionLevels( $index, $user );
}
/**
* @return string One of 'subcat', 'file', 'page'
*/
public static function getCategoryLinkType( $index ) {
- self::isMethodValidFor( $index, __METHOD__ );
-
- if ( $index == NS_CATEGORY ) {
- return 'subcat';
- } elseif ( $index == NS_FILE ) {
- return 'file';
- } else {
- return 'page';
- }
+ return MediaWikiServices::getInstance()->getNamespaceInfo()->
+ getCategoryLinkType( $index );
}
}
use ConfigFactory;
use CryptHKDF;
use CryptRand;
+use DateFormatterFactory;
use EventRelayerGroup;
use GenderCache;
use GlobalVarConfig;
use MediaWiki\Linker\LinkRendererFactory;
use MWException;
use MimeAnalyzer;
+use NamespaceInfo;
use ObjectCache;
use Parser;
use ParserCache;
return $this->getService( 'CryptRand' );
}
+ /**
+ * @since 1.33
+ * @return DateFormatterFactory
+ */
+ public function getDateFormatterFactory() {
+ return $this->getService( 'DateFormatterFactory' );
+ }
+
/**
* @since 1.28
* @return LoadBalancer The main DB load balancer for the local wiki.
return $this->getService( 'MimeAnalyzer' );
}
+ /**
+ * @since 1.33
+ * @return NamespaceInfo
+ */
+ public function getNamespaceInfo() : NamespaceInfo {
+ return $this->getService( 'NamespaceInfo' );
+ }
+
/**
* @since 1.32
* @return NameTableStoreFactory
}
/**
+ * @since 1.32
* @return OldRevisionImporter
*/
public function getOldRevisionImporter() {
[ 'cl_from' => $pageid ],
__METHOD__
);
- $type = MWNamespace::getCategoryLinkType( $this->newTitle->getNamespace() );
+ $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+ getCategoryLinkType( $this->newTitle->getNamespace() );
foreach ( $prefixes as $prefixRow ) {
$prefix = $prefixRow->cl_sortkey_prefix;
$catTo = $prefixRow->cl_to;
*/
class PHPVersionCheck {
/* @var string The number of the MediaWiki version used. */
- var $mwVersion = '1.33';
+ var $mwVersion = '1.34';
/* @var array A mapping of PHP functions to PHP extensions. */
var $functionsExtensionsMapping = array(
use MediaWiki\Special\SpecialPageFactory;
use MessageSpecifier;
use MWException;
-use MWNamespace;
+use NamespaceInfo;
use RequestContext;
use SpecialPage;
use Title;
$whitelistRead,
$whitelistReadRegexp,
$emailConfirmToEdit,
- $blockDisablesLogin
+ $blockDisablesLogin,
+ NamespaceInfo $nsInfo
) {
$this->specialPageFactory = $specialPageFactory;
$this->whitelistRead = $whitelistRead;
$this->whitelistReadRegexp = $whitelistReadRegexp;
$this->emailConfirmToEdit = $emailConfirmToEdit;
$this->blockDisablesLogin = $blockDisablesLogin;
+ $this->nsInfo = $nsInfo;
}
/**
return $errors;
}
- $isSubPage = MWNamespace::hasSubpages( $page->getNamespace() ) ?
+ $isSubPage = $this->nsInfo->hasSubpages( $page->getNamespace() ) ?
strpos( $page->getText(), '/' ) !== false : false;
if ( $action == 'create' ) {
if (
- ( MWNamespace::isTalk( $page->getNamespace() ) && !$user->isAllowed( 'createtalk' ) ) ||
- ( !MWNamespace::isTalk( $page->getNamespace() ) && !$user->isAllowed( 'createpage' ) )
+ ( $this->nsInfo->isTalk( $page->getNamespace() ) &&
+ !$user->isAllowed( 'createtalk' ) ) ||
+ ( !$this->nsInfo->isTalk( $page->getNamespace() ) &&
+ !$user->isAllowed( 'createpage' ) )
) {
$errors[] = $user->isAnon() ? [ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
}
}
} elseif ( $action == 'move' ) {
// Check for immobile pages
- if ( !MWNamespace::isMovable( $page->getNamespace() ) ) {
+ if ( !$this->nsInfo->isMovable( $page->getNamespace() ) ) {
// Specific message for this case
$errors[] = [ 'immobile-source-namespace', $page->getNsText() ];
} elseif ( !$page->isMovable() ) {
$errors[] = [ 'immobile-source-page' ];
}
} elseif ( $action == 'move-target' ) {
- if ( !MWNamespace::isMovable( $page->getNamespace() ) ) {
+ if ( !$this->nsInfo->isMovable( $page->getNamespace() ) ) {
$errors[] = [ 'immobile-target-namespace', $page->getNsText() ];
} elseif ( !$page->isMovable() ) {
$errors[] = [ 'immobile-target-page' ];
+++ /dev/null
-<?php
-/**
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- */
-
-use MediaWiki\Auth\AuthManager;
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Preferences\DefaultPreferencesFactory;
-
-/**
- * This class has been replaced by the PreferencesFactory service.
- *
- * @deprecated since 1.31 use the PreferencesFactory service instead.
- */
-class Preferences {
-
- /**
- * A shim to maintain backwards-compatibility of this class, basically replicating the
- * default behaviour of the PreferencesFactory service but not permitting overriding.
- * @return DefaultPreferencesFactory
- */
- protected static function getDefaultPreferencesFactory() {
- $services = MediaWikiServices::getInstance();
- $authManager = AuthManager::singleton();
- $linkRenderer = $services->getLinkRenderer();
- $config = $services->getMainConfig();
- $preferencesFactory = new DefaultPreferencesFactory(
- $config, $services->getContentLanguage(), $authManager,
- $linkRenderer
- );
- return $preferencesFactory;
- }
-
- /**
- * @throws MWException
- * @param User $user
- * @param IContextSource $context
- * @return array|null
- */
- public static function getPreferences( $user, IContextSource $context ) {
- wfDeprecated( __METHOD__, '1.31' );
- $preferencesFactory = self::getDefaultPreferencesFactory();
- return $preferencesFactory->getFormDescriptor( $user, $context );
- }
-
- /**
- * Loads existing values for a given array of preferences
- * @throws MWException
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences Array to load values for
- * @return array|null
- */
- public static function loadPreferenceValues( $user, $context, &$defaultPreferences ) {
- throw new Exception( __METHOD__ . '() is deprecated and does nothing' );
- }
-
- /**
- * Pull option from a user account. Handles stuff like array-type preferences.
- *
- * @param string $name
- * @param array $info
- * @param User $user
- * @return array|string
- */
- public static function getOptionFromUser( $name, $info, $user ) {
- throw new Exception( __METHOD__ . '() is deprecated and does nothing' );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- * @return void
- */
- public static function profilePreferences(
- $user, IContextSource $context, &$defaultPreferences
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- * @return void
- */
- public static function skinPreferences( $user, IContextSource $context, &$defaultPreferences ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- */
- public static function filesPreferences(
- $user, IContextSource $context, &$defaultPreferences
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- * @return void
- */
- public static function datetimePreferences(
- $user, IContextSource $context, &$defaultPreferences
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- */
- public static function renderingPreferences(
- $user, IContextSource $context, &$defaultPreferences
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- */
- public static function editingPreferences(
- $user, IContextSource $context, &$defaultPreferences
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- */
- public static function rcPreferences( $user, IContextSource $context, &$defaultPreferences ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- */
- public static function watchlistPreferences(
- $user, IContextSource $context, &$defaultPreferences
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- */
- public static function searchPreferences(
- $user, IContextSource $context, &$defaultPreferences
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $defaultPreferences = self::getPreferences( $user, $context );
- }
-
- /**
- * Dummy, kept for backwards-compatibility.
- * @param User $user
- * @param IContextSource $context
- * @param array &$defaultPreferences
- */
- public static function miscPreferences( $user, IContextSource $context, &$defaultPreferences ) {
- wfDeprecated( __METHOD__, '1.31' );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @return array Text/links to display as key; $skinkey as value
- */
- public static function generateSkinOptions( $user, IContextSource $context ) {
- wfDeprecated( __METHOD__, '1.31' );
- return self::getPreferences( $user, $context );
- }
-
- /**
- * @param IContextSource $context
- * @return array
- */
- static function getDateOptions( IContextSource $context ) {
- throw new Exception( __METHOD__ . '() is deprecated and does nothing' );
- }
-
- /**
- * @param IContextSource $context
- * @return array
- */
- public static function getImageSizes( IContextSource $context ) {
- throw new Exception( __METHOD__ . '() is deprecated and does nothing' );
- }
-
- /**
- * @param IContextSource $context
- * @return array
- */
- public static function getThumbSizes( IContextSource $context ) {
- throw new Exception( __METHOD__ . '() is deprecated and does nothing' );
- }
-
- /**
- * @param string $signature
- * @param array $alldata
- * @param HTMLForm $form
- * @return bool|string
- */
- public static function validateSignature( $signature, $alldata, $form ) {
- throw new Exception( __METHOD__ . '() is deprecated and does nothing' );
- }
-
- /**
- * @param string $signature
- * @param array $alldata
- * @param HTMLForm $form
- * @return string
- */
- public static function cleanSignature( $signature, $alldata, $form ) {
- throw new Exception( __METHOD__ . '() is deprecated and does nothing now' );
- }
-
- /**
- * @param User $user
- * @param IContextSource $context
- * @param string $formClass
- * @param array $remove Array of items to remove
- * @return PreferencesFormLegacy|HTMLForm
- */
- public static function getFormObject(
- $user,
- IContextSource $context,
- $formClass = PreferencesFormLegacy::class,
- array $remove = []
- ) {
- wfDeprecated( __METHOD__, '1.31' );
- $preferencesFactory = self::getDefaultPreferencesFactory();
- return $preferencesFactory->getForm( $user, $context, $formClass, $remove );
- }
-}
public function setReason( $msg ) {
$this->configuredReadOnly->setReason( $msg );
}
-
- /**
- * Clear the cache of the read only file
- */
- public function clearCache() {
- $this->configuredReadOnly->clearCache();
- }
}
return new CryptRand();
},
+ 'DateFormatterFactory' => function () : DateFormatterFactory {
+ return new DateFormatterFactory;
+ },
+
'DBLoadBalancer' => function ( MediaWikiServices $services ) : Wikimedia\Rdbms\LoadBalancer {
// just return the default LB from the DBLoadBalancerFactory service
return $services->getDBLoadBalancerFactory()->getMainLB();
return new MimeAnalyzer( $params );
},
+ 'NamespaceInfo' => function ( MediaWikiServices $services ) : NamespaceInfo {
+ return new NamespaceInfo( $services->getMainConfig() );
+ },
+
'NameTableStoreFactory' => function ( MediaWikiServices $services ) : NameTableStoreFactory {
return new NameTableStoreFactory(
$services->getDBLoadBalancerFactory(),
wfUrlProtocols(),
$services->getSpecialPageFactory(),
$services->getMainConfig(),
- $services->getLinkRendererFactory()
+ $services->getLinkRendererFactory(),
+ $services->getNamespaceInfo()
);
},
$config->get( 'WhitelistRead' ),
$config->get( 'WhitelistReadRegexp' ),
$config->get( 'EmailConfirmToEdit' ),
- $config->get( 'BlockDisablesLogin' ) );
+ $config->get( 'BlockDisablesLogin' ),
+ $services->getNamespaceInfo()
+ );
},
'PreferencesFactory' => function ( MediaWikiServices $services ) : PreferencesFactory {
},
'SpecialPageFactory' => function ( MediaWikiServices $services ) : SpecialPageFactory {
+ $config = $services->getMainConfig();
+ $options = [];
+ foreach ( SpecialPageFactory::$constructorOptions as $key ) {
+ $options[$key] = $config->get( $key );
+ }
return new SpecialPageFactory(
- $services->getMainConfig(),
+ $options,
$services->getContentLanguage()
);
},
$htmlForm
->setMethod( 'get' )
->setAction( wfScript() )
+ ->setCollapsible( true )
->setId( 'mw-history-searchform' )
->setSubmitText( $this->msg( 'historyaction-submit' )->text() )
->setWrapperAttributes( [ 'id' => 'mw-history-search' ] )
"apihelp-query+userinfo-paramvalue-prop-registrationdate": "Adiciona a data de registo do utilizador.",
"apihelp-query+userinfo-paramvalue-prop-unreadcount": "Adiciona a contagem de páginas não lidas da lista de páginas vigiadas do utilizador (máximo $1; devolve <samp>$2</samp> se forem mais).",
"apihelp-query+userinfo-paramvalue-prop-centralids": "Adiciona os identificadores centrais e o estado de ligação central (''attachment'') do utilizador.",
+ "apihelp-query+userinfo-paramvalue-prop-latestcontrib": "Adiciona a data da última contribuição do utilizador.",
"apihelp-query+userinfo-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indicar se o utilizador tem ligação com a wiki designada por este identificador.",
"apihelp-query+userinfo-example-simple": "Obter informações sobre o utilizador atual.",
"apihelp-query+userinfo-example-data": "Obter informações adicionais sobre o utilizador atual.",
* @ingroup Database
*/
-use MediaWiki\MediaWikiServices;
+use Wikimedia\Timestamp\ConvertibleTimestamp;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\Blob;
function __construct( array $p ) {
$p['tablePrefix'] = strtoupper( $p['tablePrefix'] );
parent::__construct( $p );
+
+ // @TODO: dependency inject
Hooks::run( 'DatabaseOraclePostInit', [ $this ] );
}
}
protected function open( $server, $user, $password, $dbName, $schema, $tablePrefix ) {
- global $wgDBOracleDRCP;
-
if ( !function_exists( 'oci_connect' ) ) {
throw new DBConnectionError(
$this,
return null;
}
- if ( $wgDBOracleDRCP ) {
- $this->setFlag( DBO_PERSISTENT );
- }
-
$session_mode = ( $this->flags & DBO_SYSDBA ) ? OCI_SYSDBA : OCI_DEFAULT;
Wikimedia\suppressWarnings();
*/
protected function doQuery( $sql ) {
wfDebug( "SQL: [$sql]\n" );
- if ( !StringUtils::isUtf8( $sql ) ) {
- throw new InvalidArgumentException( "SQL encoding is invalid\n$sql" );
+ if ( !mb_check_encoding( (string)$sql, 'UTF-8' ) ) {
+ throw new DBUnexpectedError( $this, "SQL encoding is invalid\n$sql" );
}
// handle some oracle specifics
}
if ( $val === null ) {
- if ( $col_info != false && $col_info->isNullable() == 0 && $col_info->defaultValue() != null ) {
+ if (
+ $col_info != false &&
+ $col_info->isNullable() == 0 &&
+ $col_info->defaultValue() != null
+ ) {
$bind .= 'DEFAULT';
} else {
$bind .= 'NULL';
}
// backward compatibility
- if ( preg_match( '/^timestamp.*/i', $col_type ) == 1 && strtolower( $val ) == 'infinity' ) {
+ if (
+ preg_match( '/^timestamp.*/i', $col_type ) == 1 &&
+ strtolower( $val ) == 'infinity'
+ ) {
$val = $this->getInfinity();
}
- $val = MediaWikiServices::getInstance()->getContentLanguage()->
- checkTitleEncoding( $val );
+ $val = $this->getVerifiedUTF8( $val );
if ( oci_bind_by_name( $stmt, ":$col", $val, -1, SQLT_CHR ) === false ) {
$e = oci_error( $stmt );
$this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
$lob[$col] = oci_new_descriptor( $this->conn, OCI_D_LOB );
if ( $lob[$col] === false ) {
$e = oci_error( $stmt );
- throw new DBUnexpectedError( $this, "Cannot create LOB descriptor: " . $e['message'] );
+ throw new DBUnexpectedError(
+ $this,
+ "Cannot create LOB descriptor: " . $e['message']
+ );
}
if ( is_object( $val ) ) {
if ( $sequenceData !== false &&
!isset( $varMap[$sequenceData['column']] )
) {
- $varMap[$sequenceData['column']] = 'GET_SEQUENCE_VALUE(\'' . $sequenceData['sequence'] . '\')';
+ $varMap[$sequenceData['column']] =
+ 'GET_SEQUENCE_VALUE(\'' . $sequenceData['sequence'] . '\')';
}
// count-alias subselect fields to avoid abigious definition errors
$selectJoinConds
);
- $sql = "INSERT INTO $destTable (" . implode( ',', array_keys( $varMap ) ) . ') ' . $selectSql;
+ $sql = "INSERT INTO $destTable (" .
+ implode( ',', array_keys( $varMap ) ) . ') ' . $selectSql;
if ( in_array( 'IGNORE', $insertOptions ) ) {
$this->ignoreDupValOnIndex = true;
return $this->doQuery( "DROP TABLE $tableName CASCADE CONSTRAINTS PURGE" );
}
- function timestamp( $ts = 0 ) {
- return wfTimestamp( TS_ORACLE, $ts );
+ public function timestamp( $ts = 0 ) {
+ $t = new ConvertibleTimestamp( $ts );
+ // Let errors bubble up to avoid putting garbage in the DB
+ return $t->getTimestamp( TS_ORACLE );
}
/**
*/
function fieldInfo( $table, $field ) {
if ( is_array( $table ) ) {
- throw new DBUnexpectedError( $this, 'DatabaseOracle::fieldInfo called with table array!' );
+ throw new DBUnexpectedError(
+ $this,
+ 'DatabaseOracle::fieldInfo called with table array!'
+ );
}
return $this->fieldInfoMulti( $table, $field );
}
function addQuotes( $s ) {
- $contLang = MediaWikiServices::getInstance()->getContentLanguage();
- if ( isset( $contLang->mLoaded ) && $contLang->mLoaded ) {
- $s = $contLang->checkTitleEncoding( $s );
- }
-
- return "'" . $this->strencode( $s ) . "'";
+ return "'" . $this->strencode( $this->getVerifiedUTF8( $s ) ) . "'";
}
public function addIdentifierQuotes( $s ) {
$col_type = $col_info != false ? $col_info->type() : 'CONSTANT';
if ( $col_type == 'CLOB' ) {
$col = 'TO_CHAR(' . $col . ')';
- $val =
- MediaWikiServices::getInstance()->getContentLanguage()->checkTitleEncoding( $val );
+ $val = $this->getVerifiedUTF8( $val );
} elseif ( $col_type == 'VARCHAR2' ) {
- $val =
- MediaWikiServices::getInstance()->getContentLanguage()->checkTitleEncoding( $val );
+ $val = $this->getVerifiedUTF8( $val );
}
}
$val = $val->getData();
}
- if ( preg_match( '/^timestamp.*/i', $col_type ) == 1 && strtolower( $val ) == 'infinity' ) {
+ if (
+ preg_match( '/^timestamp.*/i', $col_type ) == 1 &&
+ strtolower( $val ) == 'infinity'
+ ) {
$val = '31-12-2030 12:00:00.000000';
}
- $val = MediaWikiServices::getInstance()->getContentLanguage()->
- checkTitleEncoding( $val );
+ $val = $this->getVerifiedUTF8( $val );
if ( oci_bind_by_name( $stmt, ":$col", $val ) === false ) {
$e = oci_error( $stmt );
$this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
$lob[$col] = oci_new_descriptor( $this->conn, OCI_D_LOB );
if ( $lob[$col] === false ) {
$e = oci_error( $stmt );
- throw new DBUnexpectedError( $this, "Cannot create LOB descriptor: " . $e['message'] );
+ throw new DBUnexpectedError(
+ $this,
+ "Cannot create LOB descriptor: " . $e['message']
+ );
}
if ( is_object( $val ) ) {
public function getInfinity() {
return '31-12-2030 12:00:00.000000';
}
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ private function getVerifiedUTF8( $s ) {
+ if ( mb_check_encoding( (string)$s, 'UTF-8' ) ) {
+ return $s; // valid
+ }
+
+ throw new DBUnexpectedError( $this, "Non BLOB/CLOB field must be UTF-8." );
+ }
}
) {
global $wgCommandLineMode;
- static $typesWithSchema = [ 'postgres', 'msssql' ];
+ $typesWithSchema = self::getDbTypesWithSchemas();
$lbConf += [
'localDomain' => new DatabaseDomain(
// for Database classes in the relevant Installer subclass.
// Such as MysqlInstaller::openConnection and PostgresInstaller::openConnectionWithParams.
if ( $lbConf['class'] === Wikimedia\Rdbms\LBFactorySimple::class ) {
- $httpMethod = $_SERVER['REQUEST_METHOD'] ?? null;
- // T93097: hint for how file-based databases (e.g. sqlite) should go about locking.
- // See https://www.sqlite.org/lang_transaction.html
- // See https://www.sqlite.org/lockingv3.html#shared_lock
- $isReadRequest = in_array( $httpMethod, [ 'GET', 'HEAD', 'OPTIONS', 'TRACE' ] );
-
if ( isset( $lbConf['servers'] ) ) {
- // Server array is already explicitly configured; leave alone
+ // Server array is already explicitly configured
} elseif ( is_array( $mainConfig->get( 'DBservers' ) ) ) {
$lbConf['servers'] = [];
foreach ( $mainConfig->get( 'DBservers' ) as $i => $server ) {
- if ( $server['type'] === 'sqlite' ) {
- $server += [
- 'dbDirectory' => $mainConfig->get( 'SQLiteDataDir' ),
- 'trxMode' => $isReadRequest ? 'DEFERRED' : 'IMMEDIATE'
- ];
- } elseif ( $server['type'] === 'postgres' ) {
- $server += [
- 'port' => $mainConfig->get( 'DBport' ),
- // Work around the reserved word usage in MediaWiki schema
- 'keywordTableMap' => [ 'user' => 'mwuser', 'text' => 'pagecontent' ]
- ];
- } elseif ( $server['type'] === 'mssql' ) {
- $server += [
- 'port' => $mainConfig->get( 'DBport' ),
- 'useWindowsAuth' => $mainConfig->get( 'DBWindowsAuthentication' )
- ];
- }
-
- if ( in_array( $server['type'], $typesWithSchema, true ) ) {
- $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
- }
-
- $server += [
- 'tablePrefix' => $mainConfig->get( 'DBprefix' ),
- 'flags' => DBO_DEFAULT,
- 'sqlMode' => $mainConfig->get( 'SQLMode' ),
- ];
-
- $lbConf['servers'][$i] = $server;
+ $lbConf['servers'][$i] = self::initServerInfo( $server, $mainConfig );
}
} else {
- $flags = DBO_DEFAULT;
- $flags |= $mainConfig->get( 'DebugDumpSql' ) ? DBO_DEBUG : 0;
- $flags |= $mainConfig->get( 'DBssl' ) ? DBO_SSL : 0;
- $flags |= $mainConfig->get( 'DBcompress' ) ? DBO_COMPRESS : 0;
- $server = [
- 'host' => $mainConfig->get( 'DBserver' ),
- 'user' => $mainConfig->get( 'DBuser' ),
- 'password' => $mainConfig->get( 'DBpassword' ),
- 'dbname' => $mainConfig->get( 'DBname' ),
- 'tablePrefix' => $mainConfig->get( 'DBprefix' ),
- 'type' => $mainConfig->get( 'DBtype' ),
- 'load' => 1,
- 'flags' => $flags,
- 'sqlMode' => $mainConfig->get( 'SQLMode' ),
- 'trxMode' => $isReadRequest ? 'DEFERRED' : 'IMMEDIATE'
- ];
- if ( in_array( $server['type'], $typesWithSchema, true ) ) {
- $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
- }
- if ( $server['type'] === 'sqlite' ) {
- $server[ 'dbDirectory'] = $mainConfig->get( 'SQLiteDataDir' );
- } elseif ( $server['type'] === 'postgres' ) {
- $server['port'] = $mainConfig->get( 'DBport' );
- // Work around the reserved word usage in MediaWiki schema
- $server['keywordTableMap'] = [ 'user' => 'mwuser', 'text' => 'pagecontent' ];
- } elseif ( $server['type'] === 'mssql' ) {
- $server['port'] = $mainConfig->get( 'DBport' );
- $server['useWindowsAuth'] = $mainConfig->get( 'DBWindowsAuthentication' );
- }
+ $server = self::initServerInfo(
+ [
+ 'host' => $mainConfig->get( 'DBserver' ),
+ 'user' => $mainConfig->get( 'DBuser' ),
+ 'password' => $mainConfig->get( 'DBpassword' ),
+ 'dbname' => $mainConfig->get( 'DBname' ),
+ 'type' => $mainConfig->get( 'DBtype' ),
+ 'load' => 1
+ ],
+ $mainConfig
+ );
+
+ $server['flags'] |= $mainConfig->get( 'DBssl' ) ? DBO_SSL : 0;
+ $server['flags'] |= $mainConfig->get( 'DBcompress' ) ? DBO_COMPRESS : 0;
+
$lbConf['servers'] = [ $server ];
}
if ( !isset( $lbConf['externalClusters'] ) ) {
}
$lbConf['serverTemplate']['sqlMode'] = $mainConfig->get( 'SQLMode' );
}
- $serversCheck = $lbConf['serverTemplate'] ?? [];
+ $serversCheck = [ $lbConf['serverTemplate'] ] ?? [];
}
- self::sanityCheckServerConfig( $serversCheck, $mainConfig );
- $lbConf = self::applyDefaultCaching( $lbConf, $srvCace, $mainStash, $wanCache );
+ self::assertValidServerConfigs( $serversCheck, $mainConfig );
+
+ $lbConf = self::injectObjectCaches( $lbConf, $srvCace, $mainStash, $wanCache );
return $lbConf;
}
+ /**
+ * @return array
+ */
+ private static function getDbTypesWithSchemas() {
+ return [ 'postgres', 'msssql' ];
+ }
+
+ /**
+ * @param array $server
+ * @param Config $mainConfig
+ * @return array
+ */
+ private static function initServerInfo( array $server, Config $mainConfig ) {
+ if ( $server['type'] === 'sqlite' ) {
+ $httpMethod = $_SERVER['REQUEST_METHOD'] ?? null;
+ // T93097: hint for how file-based databases (e.g. sqlite) should go about locking.
+ // See https://www.sqlite.org/lang_transaction.html
+ // See https://www.sqlite.org/lockingv3.html#shared_lock
+ $isHttpRead = in_array( $httpMethod, [ 'GET', 'HEAD', 'OPTIONS', 'TRACE' ] );
+ $server += [
+ 'dbDirectory' => $mainConfig->get( 'SQLiteDataDir' ),
+ 'trxMode' => $isHttpRead ? 'DEFERRED' : 'IMMEDIATE'
+ ];
+ } elseif ( $server['type'] === 'postgres' ) {
+ $server += [
+ 'port' => $mainConfig->get( 'DBport' ),
+ // Work around the reserved word usage in MediaWiki schema
+ 'keywordTableMap' => [ 'user' => 'mwuser', 'text' => 'pagecontent' ]
+ ];
+ } elseif ( $server['type'] === 'mssql' ) {
+ $server += [
+ 'port' => $mainConfig->get( 'DBport' ),
+ 'useWindowsAuth' => $mainConfig->get( 'DBWindowsAuthentication' )
+ ];
+ }
+
+ if ( in_array( $server['type'], self::getDbTypesWithSchemas(), true ) ) {
+ $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
+ }
+
+ $flags = DBO_DEFAULT;
+ $flags |= $mainConfig->get( 'DebugDumpSql' ) ? DBO_DEBUG : 0;
+ if ( $server['type'] === 'oracle' ) {
+ $flags |= $mainConfig->get( 'DBOracleDRCP' ) ? DBO_PERSISTENT : 0;
+ }
+
+ $server += [
+ 'tablePrefix' => $mainConfig->get( 'DBprefix' ),
+ 'flags' => $flags,
+ 'sqlMode' => $mainConfig->get( 'SQLMode' ),
+ ];
+
+ return $server;
+ }
+
/**
* @param array $lbConf
* @param BagOStuff $sCache
* @param WANObjectCache $wCache
* @return array
*/
- private static function applyDefaultCaching(
+ private static function injectObjectCaches(
array $lbConf, BagOStuff $sCache, BagOStuff $mStash, WANObjectCache $wCache
) {
// Use APC/memcached style caching, but avoids loops with CACHE_DB (T141804)
* @param array $servers
* @param Config $mainConfig
*/
- private static function sanityCheckServerConfig( array $servers, Config $mainConfig ) {
+ private static function assertValidServerConfigs( array $servers, Config $mainConfig ) {
$ldDB = $mainConfig->get( 'DBname' ); // local domain DB
$ldTP = $mainConfig->get( 'DBprefix' ); // local domain prefix
self::purge( $this->urls );
if ( $wgCdnReboundPurgeDelay > 0 ) {
- JobQueueGroup::singleton()->lazyPush( new CdnPurgeJob(
- Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __CLASS__ ),
- [
- 'urls' => $this->urls,
- 'jobReleaseTimestamp' => time() + $wgCdnReboundPurgeDelay
- ]
- ) );
+ JobQueueGroup::singleton()->lazyPush( new CdnPurgeJob( [
+ 'urls' => $this->urls,
+ 'jobReleaseTimestamp' => time() + $wgCdnReboundPurgeDelay
+ ] ) );
}
}
$nt = Title::makeTitleSafe( NS_CATEGORY, $name );
$contLang->findVariantLink( $name, $nt, true );
- $type = MWNamespace::getCategoryLinkType( $this->mTitle->getNamespace() );
+ $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+ getCategoryLinkType( $this->mTitle->getNamespace() );
# Treat custom sortkeys as a prefix, so that if multiple
# things are forced to sort as '*' or something, they'll
* @return string
*/
function closePage() {
+ if ( $this->currentTitle !== null ) {
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+ // In rare cases, link cache has the same key for some pages which
+ // might be read as part of the same batch. T220424 and T220316
+ $linkCache->clearLink( $this->currentTitle );
+ }
return " </page>\n";
}
*/
protected $mAction = false;
+ /**
+ * Whether the HTML form can be collapsed
+ * @since 1.33
+ * @var bool
+ */
+ protected $mCollapsible = false;
+
+ /**
+ * Whether the HTML form IS collapsed by default
+ * @since 1.33
+ * @var bool
+ */
+ protected $mCollapsed = false;
+
/**
* Form attribute autocomplete. A typical value is "off". null does not set the attribute
* @since 1.27
return '' . $this->mPre . $html . $this->mPost;
}
+ /**
+ * Make the form collapsible
+ * @since 1.33
+ * @param bool $collapsed whether it should be by default
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setCollapsible( $collapsed = false ) {
+ $this->mCollapsible = true;
+ $this->mCollapsed = $collapsed;
+ return $this;
+ }
+
/**
* Get HTML attributes for the `<form>` tag.
* @return array
public function wrapForm( $html ) {
if ( is_string( $this->mWrapperLegend ) ) {
+ $classes = $this->mCollapsible ? [ 'mw-collapsible' ] : [];
+ if ( $this->mCollapsed ) {
+ $classes[] = 'mw-collapsed';
+ }
$content = new OOUI\FieldsetLayout( [
'label' => $this->mWrapperLegend,
- 'items' => [
- new OOUI\Widget( [
- 'content' => new OOUI\HtmlSnippet( $html )
- ] ),
- ],
+ 'classes' => $classes,
+ 'group' => new OOUI\StackLayout( [
+ 'expanded' => false,
+ 'classes' => [ 'oo-ui-fieldsetLayout-group mw-collapsible-content' ],
+ 'items' => [
+ new OOUI\Widget( [
+ 'content' => new OOUI\HtmlSnippet( $html )
+ ] ),
+ ],
+ ] ),
] + OOUI\Element::configFromHtmlAttributes( $this->mWrapperAttributes ) );
} else {
$content = new OOUI\HtmlSnippet( $html );
}
+ $classes = [ 'mw-htmlform', 'mw-htmlform-ooui' ];
$form = new OOUI\FormLayout( $this->getFormAttributes() + [
- 'classes' => [ 'mw-htmlform', 'mw-htmlform-ooui' ],
+ 'classes' => $classes,
'content' => $content,
] );
}
if ( $this->mType === 'date' ) {
+ $this->mParent->getOutput()->addModuleStyles( 'mediawiki.widgets.DateInputWidget.styles' );
return new MediaWiki\Widget\DateInputWidget( $params );
} else {
return new MediaWiki\Widget\DateTimeInputWidget( $params );
* @file
* @ingroup Deployment
*/
-use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IMaintainableDatabase;
use MediaWiki\MediaWikiServices;
/**
* Handle to the database subclass
*
- * @var Database
+ * @var IMaintainableDatabase
*/
protected $db;
protected $holdContentHandlerUseDB = true;
/**
- * @param Database &$db To perform updates on
+ * @param IMaintainableDatabase &$db To perform updates on
* @param bool $shared Whether to perform updates on shared tables
* @param Maintenance|null $maintenance Maintenance object which created us
*/
- protected function __construct( Database &$db, $shared, Maintenance $maintenance = null ) {
+ protected function __construct(
+ IMaintainableDatabase &$db,
+ $shared,
+ Maintenance $maintenance = null
+ ) {
$this->db = $db;
$this->db->setFlag( DBO_DDLMODE ); // For Oracle's handling of schema files
$this->shared = $shared;
/**
* Get a database connection to run updates
*
- * @return Database
+ * @return IMaintainableDatabase
*/
public function getDB() {
return $this->db;
--- /dev/null
+<?php
+/**
+ * Interface for generic jobs only uses the parameters field.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for generic jobs only uses the parameters field and are JSON serializable
+ *
+ * @ingroup JobQueue
+ * @since 1.33
+ */
+interface GenericParameterJob extends IJobSpecification {
+ /**
+ * @param array $params JSON-serializable map of parameters
+ */
+ public function __construct( array $params );
+}
<?php
/**
- * Job queue task description base code.
+ * Job queue task description interface
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
*/
/**
- * Job queue task description interface
+ * Interface for serializable objects that describe a job queue task
+ *
+ * A job specification can be inserted into a queue via JobQueue::push().
+ * The specification parameters should be JSON serializable (e.g. no PHP classes).
+ * Whatever queue the job specification is pushed into is assumed to have job runners
+ * that will eventually pop the job specification from the queue, construct a RunnableJob
+ * instance from the specification, and then execute that instance via RunnableJob::run().
*
* @ingroup JobQueue
* @since 1.23
*/
interface IJobSpecification {
/**
- * @return string Job type
+ * @return string Job type that defines what sort of changes this job makes
*/
public function getType();
/**
- * @return array
+ * @return array Parameters that specify sources, targets, and options for execution
*/
public function getParams();
* @return bool Whether this is job is a root job
*/
public function isRootJob();
-
- /**
- * @return Title Descriptive title (this can simply be informative)
- */
- public function getTitle();
}
*
* @ingroup JobQueue
*/
-abstract class Job implements IJobSpecification {
+abstract class Job implements RunnableJob {
/** @var string */
public $command;
/** @var int Job must not be wrapped in the usual explicit LBFactory transaction round */
const JOB_NO_EXPLICIT_TRX_ROUND = 1;
- /**
- * Run the job
- * @return bool Success
- */
- abstract public function run();
-
/**
* Create the appropriate object to handle a specific job
*
$title = $params;
$params = func_num_args() >= 3 ? func_get_arg( 2 ) : [];
} else {
- // Subclasses can override getTitle() to return something more meaningful
- $title = Title::makeTitle( NS_SPECIAL, 'Blankpage' );
+ $title = ( isset( $params['namespace'] ) && isset( $params['title'] ) )
+ ? Title::makeTitle( $params['namespace'], $params['title'] )
+ : Title::makeTitle( NS_SPECIAL, '' );
}
+ $params = is_array( $params ) ? $params : []; // sanity
+
if ( isset( $wgJobClasses[$command] ) ) {
$handler = $wgJobClasses[$command];
if ( is_callable( $handler ) ) {
$job = call_user_func( $handler, $title, $params );
} elseif ( class_exists( $handler ) ) {
- $job = new $handler( $title, $params );
+ if ( is_subclass_of( $handler, GenericParameterJob::class ) ) {
+ $job = new $handler( $params );
+ } else {
+ $job = new $handler( $title, $params );
+ }
} else {
$job = null;
}
if ( $params instanceof Title ) {
// Backwards compatibility for old signature ($command, $title, $params)
$title = $params;
- $params = func_get_arg( 2 );
- } else {
- // Subclasses can override getTitle() to return something more meaningful
- $title = Title::makeTitle( NS_SPECIAL, 'Blankpage' );
+ $params = func_num_args() >= 3 ? func_get_arg( 2 ) : [];
+ $params = is_array( $params ) ? $params : []; // sanity
+ // Set namespace/title params if both are missing and this is not a dummy title
+ if (
+ $title->getDBkey() !== '' &&
+ !isset( $params['namespace'] ) &&
+ !isset( $params['title'] )
+ ) {
+ $params['namespace'] = $title->getNamespace();
+ $params['title'] = $title->getDBKey();
+ // Note that JobQueue classes will prefer the parameters over getTitle()
+ $this->title = $title;
+ }
}
$this->command = $command;
- $this->title = $title;
- $this->params = is_array( $params ) ? $params : [];
- if ( !isset( $this->params['requestId'] ) ) {
- $this->params['requestId'] = WebRequest::getRequestId();
+ $this->params = $params + [ 'requestId' => WebRequest::getRequestId() ];
+ if ( $this->title === null ) {
+ $this->title = ( isset( $params['namespace'] ) && isset( $params['title'] ) )
+ ? Title::makeTitle( $params['namespace'], $params['title'] )
+ : Title::makeTitle( NS_SPECIAL, '' );
}
}
/**
* @return Title
*/
- public function getTitle() {
+ final public function getTitle() {
return $this->title;
}
public function getDeduplicationInfo() {
$info = [
'type' => $this->getType(),
- 'namespace' => $this->getTitle()->getNamespace(),
- 'title' => $this->getTitle()->getDBkey(),
'params' => $this->getParams()
];
if ( is_array( $info['params'] ) ) {
* Outside callers should use JobQueueGroup::pop() instead of this function.
*
* @throws JobQueueError
- * @return Job|bool Returns false if there are no jobs
+ * @return RunnableJob|bool Returns false if there are no jobs
*/
final public function pop() {
$this->assertNotReadOnly();
/**
* @see JobQueue::pop()
- * @return Job|bool
+ * @return RunnableJob|bool
*/
abstract protected function doPop();
* This does nothing for certain queue classes or if "claimTTL" is not set.
* Outside callers should use JobQueueGroup::ack() instead of this function.
*
- * @param Job $job
+ * @param RunnableJob $job
* @return void
* @throws JobQueueError
*/
- final public function ack( Job $job ) {
+ final public function ack( RunnableJob $job ) {
$this->assertNotReadOnly();
if ( $job->getType() !== $this->type ) {
throw new JobQueueError( "Got '{$job->getType()}' job; expected '{$this->type}'." );
/**
* @see JobQueue::ack()
- * @param Job $job
+ * @param RunnableJob $job
*/
- abstract protected function doAck( Job $job );
+ abstract protected function doAck( RunnableJob $job );
/**
* Register the "root job" of a given job into the queue for de-duplication.
/**
* Check if the "root" job of a given job has been superseded by a newer one
*
- * @param Job $job
+ * @param IJobSpecification $job
* @throws JobQueueError
* @return bool
*/
- final protected function isRootJobOldDuplicate( Job $job ) {
+ final protected function isRootJobOldDuplicate( IJobSpecification $job ) {
if ( $job->getType() !== $this->type ) {
throw new JobQueueError( "Got '{$job->getType()}' job; expected '{$this->type}'." );
}
/**
* @see JobQueue::isRootJobOldDuplicate()
- * @param Job $job
+ * @param IJobSpecification $job
* @return bool
*/
- protected function doIsRootJobOldDuplicate( Job $job ) {
+ protected function doIsRootJobOldDuplicate( IJobSpecification $job ) {
if ( !$job->hasRootJobParams() ) {
return false; // job has no de-deplication info
}
return null; // not supported
}
+ /**
+ * @param string $command
+ * @param array $params
+ * @return Job
+ */
+ protected function factoryJob( $command, $params ) {
+ // @TODO: dependency inject this as a callback
+ return Job::factory( $command, $params );
+ }
+
/**
* @throws JobQueueReadOnlyError
*/
/**
* @see JobQueue::doPop()
- * @return Job|bool
+ * @return RunnableJob|bool
*/
protected function doPop() {
$dbw = $this->getMasterDB();
break; // nothing to do
}
$this->incrStats( 'pops', $this->type );
+
// Get the job object from the row...
- $title = Title::makeTitle( $row->job_namespace, $row->job_title );
- $job = Job::factory( $row->job_cmd, $title,
- self::extractBlob( $row->job_params ) );
+ $params = self::extractBlob( $row->job_params );
+ $params = is_array( $params ) ? $params : []; // sanity
+ $params += [ 'namespace' => $row->job_namespace, 'title' => $row->job_title ];
+ $job = $this->factoryJob( $row->job_cmd, $params );
$job->setMetadata( 'id', $row->job_id );
$job->setMetadata( 'timestamp', $row->job_timestamp );
break; // done
/**
* @see JobQueue::doAck()
- * @param Job $job
+ * @param RunnableJob $job
* @throws MWException
*/
- protected function doAck( Job $job ) {
+ protected function doAck( RunnableJob $job ) {
$id = $job->getMetadata( 'id' );
if ( $id === null ) {
throw new MWException( "Job of type '{$job->getType()}' has no ID." );
return new MappedIterator(
$dbr->select( 'job', self::selectFields(), $conds ),
function ( $row ) {
- $job = Job::factory(
- $row->job_cmd,
- Title::makeTitle( $row->job_namespace, $row->job_title ),
- strlen( $row->job_params ) ? unserialize( $row->job_params ) : []
- );
+ $params = strlen( $row->job_params ) ? unserialize( $row->job_params ) : [];
+ $params = is_array( $params ) ? $params : []; // sanity
+ $params += [
+ 'namespace' => $row->job_namespace,
+ 'title' => $row->job_title
+ ];
+
+ $job = $this->factoryJob( $row->job_cmd, $params );
$job->setMetadata( 'id', $row->job_id );
$job->setMetadata( 'timestamp', $row->job_timestamp );
return [
// Fields that describe the nature of the job
'job_cmd' => $job->getType(),
- 'job_namespace' => $job->getTitle()->getNamespace(),
- 'job_title' => $job->getTitle()->getDBkey(),
+ 'job_namespace' => $job->getParams()['namespace'] ?? NS_SPECIAL,
+ 'job_title' => $job->getParams()['title'] ?? '',
'job_params' => self::makeBlob( $job->getParams() ),
// Additional job metadata
'job_timestamp' => $db->timestamp(),
* @param HashRing &$partitionRing
* @param int $flags
* @throws JobQueueError
- * @return array List of Job object that could not be inserted
+ * @return IJobSpecification[] List of Job object that could not be inserted
*/
protected function tryJobInsertions( array $jobs, HashRing &$partitionRing, $flags ) {
$jobsLeft = [];
return false;
}
- protected function doAck( Job $job ) {
+ protected function doAck( RunnableJob $job ) {
$partition = $job->getMetadata( 'QueuePartition' );
if ( $partition === null ) {
throw new MWException( "The given job has no defined partition name." );
$this->partitionQueues[$partition]->ack( $job );
}
- protected function doIsRootJobOldDuplicate( Job $job ) {
+ protected function doIsRootJobOldDuplicate( IJobSpecification $job ) {
$signature = $job->getRootJobParams()['rootJobSignature'];
$partition = $this->partitionRing->getLiveLocation( $signature );
try {
* @param int|string $qtype JobQueueGroup::TYPE_* constant or job type string
* @param int $flags Bitfield of JobQueueGroup::USE_* constants
* @param array $blacklist List of job types to ignore
- * @return Job|bool Returns false on failure
+ * @return RunnableJob|bool Returns false on failure
*/
public function pop( $qtype = self::TYPE_DEFAULT, $flags = 0, array $blacklist = [] ) {
global $wgJobClasses;
/**
* @see JobQueue::doPop
*
- * @return Job|bool
+ * @return RunnableJob|bool
*/
protected function doPop() {
if ( $this->doGetSize() == 0 ) {
/**
* @see JobQueue::doAck
*
- * @param Job $job
+ * @param RunnableJob $job
*/
- protected function doAck( Job $job ) {
+ protected function doAck( RunnableJob $job ) {
if ( $this->getAcquiredCount() == 0 ) {
return;
}
/**
* @param IJobSpecification $spec
- *
- * @return Job
+ * @return RunnableJob
*/
public function jobFromSpecInternal( IJobSpecification $spec ) {
- return Job::factory( $spec->getType(), $spec->getTitle(), $spec->getParams() );
+ return $this->factoryJob( $spec->getType(), $spec->getParams() );
}
/**
/**
* @see JobQueue::doPop()
- * @return Job|bool
+ * @return RunnableJob|bool
* @throws JobQueueError
*/
protected function doPop() {
/**
* @see JobQueue::doAck()
- * @param Job $job
- * @return Job|bool
+ * @param RunnableJob $job
+ * @return RunnableJob|bool
* @throws UnexpectedValueException
* @throws JobQueueError
*/
- protected function doAck( Job $job ) {
+ protected function doAck( RunnableJob $job ) {
$uuid = $job->getMetadata( 'uuid' );
if ( $uuid === null ) {
throw new UnexpectedValueException( "Job of type '{$job->getType()}' has no UUID." );
/**
* @see JobQueue::doIsRootJobOldDuplicate()
- * @param Job $job
+ * @param IJobSpecification $job
* @return bool
* @throws JobQueueError
*/
- protected function doIsRootJobOldDuplicate( Job $job ) {
+ protected function doIsRootJobOldDuplicate( IJobSpecification $job ) {
if ( !$job->hasRootJobParams() ) {
return false; // job has no de-deplication info
}
*
* @param string $uid
* @param RedisConnRef $conn
- * @return Job|bool Returns false if the job does not exist
+ * @return RunnableJob|bool Returns false if the job does not exist
* @throws JobQueueError
* @throws UnexpectedValueException
*/
if ( !is_array( $item ) ) { // this shouldn't happen
throw new UnexpectedValueException( "Could not find job with ID '$uid'." );
}
- $title = Title::makeTitle( $item['namespace'], $item['title'] );
- $job = Job::factory( $item['type'], $title, $item['params'] );
+
+ $params = $item['params'];
+ $params += [ 'namespace' => $item['namespace'], 'title' => $item['title'] ];
+ $job = $this->factoryJob( $item['type'], $params );
$job->setMetadata( 'uuid', $item['uuid'] );
$job->setMetadata( 'timestamp', $item['timestamp'] );
// Add in attempt count for debugging at showJobs.php
return [
// Fields that describe the nature of the job
'type' => $job->getType(),
- 'namespace' => $job->getTitle()->getNamespace(),
- 'title' => $job->getTitle()->getDBkey(),
+ 'namespace' => $job->getParams()['namespace'] ?? NS_SPECIAL,
+ 'title' => $job->getParams()['title'] ?? '',
'params' => $job->getParams(),
// Some jobs cannot run until a "release timestamp"
'rtimestamp' => $job->getReleaseTimestamp() ?: 0,
/**
* @param array $fields
- * @return Job|bool
+ * @return RunnableJob|bool
*/
protected function getJobFromFields( array $fields ) {
- $title = Title::makeTitle( $fields['namespace'], $fields['title'] );
- $job = Job::factory( $fields['type'], $title, $fields['params'] );
+ $params = $fields['params'];
+ $params += [ 'namespace' => $fields['namespace'], 'title' => $fields['title'] ];
+
+ $job = $this->factoryJob( $fields['type'], $params );
$job->setMetadata( 'uuid', $fields['uuid'] );
$job->setMetadata( 'timestamp', $fields['timestamp'] );
* $job = new JobSpecification(
* 'null',
* array( 'lives' => 1, 'usleep' => 100, 'pi' => 3.141569 ),
- * array( 'removeDuplicates' => 1 ),
- * Title::makeTitle( NS_SPECIAL, 'nullity' )
+ * array( 'removeDuplicates' => 1 )
* );
* JobQueueGroup::singleton()->push( $job )
* @endcode
$this->validateParams( $opts );
$this->type = $type;
+ if ( $title instanceof Title ) {
+ // Make sure JobQueue classes can pull the title from parameters alone
+ if ( $title->getDBkey() !== '' ) {
+ $params += [
+ 'namespace' => $title->getNamespace(),
+ 'title' => $title->getDBkey()
+ ];
+ }
+ } else {
+ $title = Title::makeTitle( NS_SPECIAL, '' );
+ }
$this->params = $params;
- $this->title = $title ?: Title::makeTitle( NS_SPECIAL, 'Blankpage' );
+ $this->title = $title;
$this->opts = $opts;
}
--- /dev/null
+<?php
+/**
+ * Job queue task instance that can be executed via a run() method
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Job that has a run() method and metadata accessors for JobQueue::pop() and JobQueue::ack()
+ *
+ * Instances are not only enqueueable via JobQueue::push(), but they can also be executed by
+ * by calling their run() method. When constructing a job to be enqueued via JobQueue::push(),
+ * it will not be possible to construct a RunnableJob instance if the class for that job is not
+ * loaded by the application for the local DB domain. In that case, the general-purpose
+ * JobSpecification class can be used instead.
+ *
+ * @ingroup JobQueue
+ * @since 1.33
+ */
+interface RunnableJob extends IJobSpecification {
+ /**
+ * Run the job
+ * @return bool Success
+ */
+ public function run();
+
+ /**
+ * @param string|null $field Metadata field or null to get all the metadata
+ * @return mixed|null Value; null if missing
+ */
+ public function getMetadata( $field = null );
+
+ /**
+ * @param string $field Key name to set the value for
+ * @param mixed $value The value to set the field for
+ * @return mixed|null The prior field value; null if missing
+ */
+ public function setMetadata( $field, $value );
+}
* @ingroup JobQueue
* @since 1.27
*/
-class CdnPurgeJob extends Job {
- /**
- * @param Title $title
- * @param array $params Job parameters (urls)
- */
- function __construct( Title $title, array $params ) {
- parent::__construct( 'cdnPurge', $title, $params );
+class CdnPurgeJob extends Job implements GenericParameterJob {
+ function __construct( array $params ) {
+ parent::__construct( 'cdnPurge', $params );
$this->removeDuplicates = false; // delay semantics are critical
}
* @ingroup JobQueue
* @since 1.31
*/
-class ClearUserWatchlistJob extends Job {
+class ClearUserWatchlistJob extends Job implements GenericParameterJob {
+ /**
+ * @param array $params
+ * - userId, The ID for the user whose watchlist is being cleared.
+ * - maxWatchlistId, The maximum wl_id at the time the job was first created,
+ */
+ public function __construct( array $params ) {
+ parent::__construct( 'clearUserWatchlist', $params );
+
+ $this->removeDuplicates = true;
+ }
/**
* @param User $user User to clear the watchlist for.
* @return ClearUserWatchlistJob
*/
public static function newForUser( User $user, $maxWatchlistId ) {
- return new self(
- null,
- [ 'userId' => $user->getId(), 'maxWatchlistId' => $maxWatchlistId ]
- );
- }
-
- /**
- * @param Title|null $title Not used by this job.
- * @param array $params
- * - userId, The ID for the user whose watchlist is being cleared.
- * - maxWatchlistId, The maximum wl_id at the time the job was first created,
- */
- public function __construct( Title $title = null, array $params ) {
- parent::__construct(
- 'clearUserWatchlist',
- SpecialPage::getTitleFor( 'EditWatchlist', 'clear' ),
- $params
- );
-
- $this->removeDuplicates = true;
+ return new self( [ 'userId' => $user->getId(), 'maxWatchlistId' => $maxWatchlistId ] );
}
public function run() {
if ( count( $watchlistIds ) === (int)$batchSize ) {
// Until we get less results than the limit, recursively push
// the same job again.
- JobQueueGroup::singleton()->push( new self( $this->getTitle(), $this->getParams() ) );
+ JobQueueGroup::singleton()->push( new self( $this->getParams() ) );
}
return true;
* @ingroup JobQueue
* @since 1.31
*/
-class ClearWatchlistNotificationsJob extends Job {
- function __construct( Title $title, array $params ) {
- parent::__construct( 'clearWatchlistNotifications', $title, $params );
+class ClearWatchlistNotificationsJob extends Job implements GenericParameterJob {
+ function __construct( array $params ) {
+ parent::__construct( 'clearWatchlistNotifications', $params );
static $required = [ 'userId', 'casTime' ];
$missing = implode( ', ', array_diff( $required, array_keys( $this->params ) ) );
/**
* Class DeletePageJob
*/
-class DeletePageJob extends Job {
- public function __construct( $title, $params = [] ) {
- parent::__construct( 'deletePage', $title, $params );
+class DeletePageJob extends Job implements GenericParameterJob {
+ public function __construct( array $params ) {
+ parent::__construct( 'deletePage', $params );
+
+ $this->title = Title::makeTitle( $params['namespace'], $params['title'] );
}
- /**
- * Execute the job
- *
- * @return bool
- */
public function run() {
// Failure to load the page is not job failure.
// A parallel deletion operation may have already completed the page deletion.
*
* @ingroup JobQueue
*/
-final class DuplicateJob extends Job {
+final class DuplicateJob extends Job implements GenericParameterJob {
/**
* Callers should use DuplicateJob::newFromJob() instead
*
- * @param Title $title
* @param array $params Job parameters
*/
- function __construct( Title $title, array $params ) {
- parent::__construct( 'duplicate', $title, $params );
+ function __construct( array $params ) {
+ parent::__construct( 'duplicate', $params );
}
/**
* Get a duplicate no-op version of a job
*
- * @param Job $job
+ * @param RunnableJob $job
* @return Job
*/
- public static function newFromJob( Job $job ) {
- $djob = new self( $job->getTitle(), $job->getParams() );
+ public static function newFromJob( RunnableJob $job ) {
+ $djob = new self( $job->getParams() );
$djob->command = $job->getType();
$djob->params = is_array( $djob->params ) ? $djob->params : [];
$djob->params = [ 'isDuplicate' => true ] + $djob->params;
- $djob->metadata = $job->metadata;
+ $djob->metadata = $job->getMetadata();
return $djob;
}
* @ingroup JobQueue
* @since 1.25
*/
-final class EnqueueJob extends Job {
+final class EnqueueJob extends Job implements GenericParameterJob {
/**
* Callers should use the factory methods instead
*
- * @param Title $title
* @param array $params Job parameters
*/
- function __construct( Title $title, array $params ) {
- parent::__construct( 'enqueue', $title, $params );
+ public function __construct( array $params ) {
+ parent::__construct( 'enqueue', $params );
}
/**
}
}
- $eJob = new self(
- Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __CLASS__ ),
- [ 'jobsByDomain' => $jobMapsByDomain ]
- );
+ $eJob = new self( [ 'jobsByDomain' => $jobMapsByDomain ] );
// If *all* jobs to be pushed are to be de-duplicated (a common case), then
// de-duplicate this whole job itself to avoid build up in high traffic cases
$eJob->removeDuplicates = $deduplicate;
* @code
* $ php maintenance/eval.php
* > $queue = JobQueueGroup::singleton();
- * > $job = new NullJob( Title::newMainPage(), [ 'lives' => 10 ] );
+ * > $job = new NullJob( [ 'lives' => 10 ] );
* > $queue->push( $job );
* @endcode
* You can then confirm the job has been enqueued by using the showJobs.php
*
* @ingroup JobQueue
*/
-class NullJob extends Job {
+class NullJob extends Job implements GenericParameterJob {
/**
- * @param Title $title
* @param array $params Job parameters (lives, usleep)
*/
- function __construct( Title $title, array $params ) {
- parent::__construct( 'null', $title, $params );
+ function __construct( array $params ) {
+ parent::__construct( 'null', $params );
if ( !isset( $this->params['lives'] ) ) {
$this->params['lives'] = 1;
}
if ( $this->params['lives'] > 1 ) {
$params = $this->params;
$params['lives']--;
- $job = new self( $this->title, $params );
+ $job = new self( $params );
JobQueueGroup::singleton()->push( $job );
}
* @ingroup JobQueue
*/
-class UserGroupExpiryJob extends Job {
- public function __construct( $params = [] ) {
- parent::__construct( 'userGroupExpiry', Title::newMainPage(), $params );
+class UserGroupExpiryJob extends Job implements GenericParameterJob {
+ public function __construct( array $params = [] ) {
+ parent::__construct( 'userGroupExpiry', $params );
$this->removeDuplicates = true;
}
$dbw->endAtomic( __METHOD__ );
$jobParams = [
+ 'namespace' => $this->getTitle()->getNamespace(),
+ 'title' => $this->getTitle()->getDBkey(),
'wikiPageId' => $id,
'requestId' => $webRequestId ?? WebRequest::getRequestId(),
'reason' => $reason,
'logsubtype' => $logsubtype,
];
- $job = new DeletePageJob( $this->getTitle(), $jobParams );
+ $job = new DeletePageJob( $jobParams );
JobQueueGroup::singleton()->push( $job );
$status->warning( 'delete-scheduled',
*/
public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
$id = $id ?: $this->getId();
- $type = MWNamespace::getCategoryLinkType( $this->getTitle()->getNamespace() );
+ $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+ getCategoryLinkType( $this->getTitle()->getNamespace() );
$addFields = [ 'cat_pages = cat_pages + 1' ];
$removeFields = [ 'cat_pages = cat_pages - 1' ];
*/
public static function formatDate( $parser, $date, $defaultPref = null ) {
$lang = $parser->getFunctionLang();
- $df = DateFormatter::getInstance( $lang );
+ $df = MediaWikiServices::getInstance()->getDateFormatterFactory()->get( $lang );
$date = trim( $date );
use MediaWiki\MediaWikiServices;
/**
- * Date formatter, recognises dates in plain text and formats them according to user preferences.
- * @todo preferences, OutputPage
+ * Date formatter. Recognises dates and formats them according to a specified preference.
+ *
+ * This class was originally introduced to detect and transform dates in free text. It is now
+ * only used by the {{#dateformat}} parser function. This is a very rudimentary date formatter;
+ * Language::sprintfDate() has many more features and is the correct choice for most new code.
+ * The main advantage of this date formatter is that it is able to format incomplete dates with an
+ * unspecified year.
+ *
* @ingroup Parser
*/
class DateFormatter {
- private $mSource, $mTarget;
- private $monthNames = '';
-
+ /** @var string[] Date format regexes indexed the class constants */
private $regexes;
- private $rules, $xMonths, $preferences;
- private $lang, $mLinked;
+ /**
+ * @var int[][] Array of special rules. The first key is the preference ID
+ * (one of the class constants), the second key is the detected source
+ * format, and the value is the ID of the target format that will be used
+ * in that case.
+ */
+ private $rules = [];
- /** @var string[] */
- private $keys;
+ /**
+ * @var int[] Month numbers by lowercase name
+ */
+ private $xMonths = [];
- /** @var string[] */
- private $targets;
+ /**
+ * @var string[] Month names by number
+ */
+ private $monthNames = [];
+ /**
+ * @var int[] A map of descriptive preference text to internal format ID
+ */
+ private $preferenceIDs;
+
+ /** @var string[] Format strings similar to those used by date(), indexed by ID */
+ private $targetFormats;
+
+ /** Used as a preference ID for rules that apply regardless of preference */
const ALL = -1;
+
+ /** No preference: the date may be left in the same format as the input */
const NONE = 0;
+
+ /** e.g. January 15, 2001 */
const MDY = 1;
+
+ /** e.g. 15 January 2001 */
const DMY = 2;
+
+ /** e.g. 2001 January 15 */
const YMD = 3;
- const ISO1 = 4;
+
+ /** e.g. 2001-01-15 */
+ const ISO = 4;
+
+ /** The highest ID that is a valid user preference */
const LASTPREF = 4;
- const ISO2 = 5;
- const YDM = 6;
- const DM = 7;
- const MD = 8;
- const LAST = 8;
+
+ /** e.g. 2001, 15 January */
+ const YDM = 5;
+
+ /** e.g. 15 January */
+ const DM = 6;
+
+ /** e.g. January 15 */
+ const MD = 7;
+
+ /** The highest ID that is a valid target format */
+ const LAST = 7;
/**
* @param Language $lang In which language to format the date
*/
public function __construct( Language $lang ) {
- $this->lang = $lang;
-
- $this->monthNames = $this->getMonthRegex();
+ $monthRegexParts = [];
for ( $i = 1; $i <= 12; $i++ ) {
- $this->xMonths[$this->lang->lc( $this->lang->getMonthName( $i ) )] = $i;
- $this->xMonths[$this->lang->lc( $this->lang->getMonthAbbreviation( $i ) )] = $i;
+ $monthName = $lang->getMonthName( $i );
+ $monthAbbrev = $lang->getMonthAbbreviation( $i );
+ $this->monthNames[$i] = $monthName;
+ $monthRegexParts[] = preg_quote( $monthName, '/' );
+ $monthRegexParts[] = preg_quote( $monthAbbrev, '/' );
+ $this->xMonths[mb_strtolower( $monthName )] = $i;
+ $this->xMonths[mb_strtolower( $monthAbbrev )] = $i;
}
- $this->regexTrail = '(?![a-z])/iu';
-
- # Partial regular expressions
- $this->prxDM = '\[\[(\d{1,2})[ _](' . $this->monthNames . ')\]\]';
- $this->prxMD = '\[\[(' . $this->monthNames . ')[ _](\d{1,2})\]\]';
- $this->prxY = '\[\[(\d{1,4}([ _]BC|))\]\]';
- $this->prxISO1 = '\[\[(-?\d{4})]]-\[\[(\d{2})-(\d{2})\]\]';
- $this->prxISO2 = '\[\[(-?\d{4})-(\d{2})-(\d{2})\]\]';
-
- # Real regular expressions
- $this->regexes[self::DMY] = "/{$this->prxDM}(?: *, *| +){$this->prxY}{$this->regexTrail}";
- $this->regexes[self::YDM] = "/{$this->prxY}(?: *, *| +){$this->prxDM}{$this->regexTrail}";
- $this->regexes[self::MDY] = "/{$this->prxMD}(?: *, *| +){$this->prxY}{$this->regexTrail}";
- $this->regexes[self::YMD] = "/{$this->prxY}(?: *, *| +){$this->prxMD}{$this->regexTrail}";
- $this->regexes[self::DM] = "/{$this->prxDM}{$this->regexTrail}";
- $this->regexes[self::MD] = "/{$this->prxMD}{$this->regexTrail}";
- $this->regexes[self::ISO1] = "/{$this->prxISO1}{$this->regexTrail}";
- $this->regexes[self::ISO2] = "/{$this->prxISO2}{$this->regexTrail}";
-
- # Extraction keys
- # See the comments in replace() for the meaning of the letters
- $this->keys[self::DMY] = 'jFY';
- $this->keys[self::YDM] = 'Y jF';
- $this->keys[self::MDY] = 'FjY';
- $this->keys[self::YMD] = 'Y Fj';
- $this->keys[self::DM] = 'jF';
- $this->keys[self::MD] = 'Fj';
- $this->keys[self::ISO1] = 'ymd'; # y means ISO year
- $this->keys[self::ISO2] = 'ymd';
-
- # Target date formats
- $this->targets[self::DMY] = '[[F j|j F]] [[Y]]';
- $this->targets[self::YDM] = '[[Y]], [[F j|j F]]';
- $this->targets[self::MDY] = '[[F j]], [[Y]]';
- $this->targets[self::YMD] = '[[Y]] [[F j]]';
- $this->targets[self::DM] = '[[F j|j F]]';
- $this->targets[self::MD] = '[[F j]]';
- $this->targets[self::ISO1] = '[[Y|y]]-[[F j|m-d]]';
- $this->targets[self::ISO2] = '[[y-m-d]]';
-
- # Rules
- # pref source target
+ // Partial regular expressions
+ $monthNames = implode( '|', $monthRegexParts );
+ $dm = "(?<day>\d{1,2})[ _](?<monthName>{$monthNames})";
+ $md = "(?<monthName>{$monthNames})[ _](?<day>\d{1,2})";
+ $y = '(?<year>\d{1,4}([ _]BC|))';
+ $iso = '(?<isoYear>-?\d{4})-(?<isoMonth>\d{2})-(?<isoDay>\d{2})';
+
+ $this->regexes = [
+ self::DMY => "/^{$dm}(?: *, *| +){$y}$/iu",
+ self::YDM => "/^{$y}(?: *, *| +){$dm}$/iu",
+ self::MDY => "/^{$md}(?: *, *| +){$y}$/iu",
+ self::YMD => "/^{$y}(?: *, *| +){$md}$/iu",
+ self::DM => "/^{$dm}$/iu",
+ self::MD => "/^{$md}$/iu",
+ self::ISO => "/^{$iso}$/iu",
+ ];
+
+ // Target date formats
+ $this->targetFormats = [
+ self::DMY => 'j F Y',
+ self::YDM => 'Y, j F',
+ self::MDY => 'F j, Y',
+ self::YMD => 'Y F j',
+ self::DM => 'j F',
+ self::MD => 'F j',
+ self::ISO => 'y-m-d',
+ ];
+
+ // Rules
+ // pref source target
$this->rules[self::DMY][self::MD] = self::DM;
$this->rules[self::ALL][self::MD] = self::MD;
$this->rules[self::MDY][self::DM] = self::MD;
$this->rules[self::ALL][self::DM] = self::DM;
- $this->rules[self::NONE][self::ISO2] = self::ISO1;
+ $this->rules[self::NONE][self::ISO] = self::ISO;
- $this->preferences = [
+ $this->preferenceIDs = [
'default' => self::NONE,
'dmy' => self::DMY,
'mdy' => self::MDY,
'ymd' => self::YMD,
- 'ISO 8601' => self::ISO1,
+ 'ISO 8601' => self::ISO,
];
}
/**
* Get a DateFormatter object
*
+ * @deprecated since 1.33 use MediaWikiServices::getDateFormatterFactory()
+ *
* @param Language|null $lang In which language to format the date
* Defaults to the site content language
* @return DateFormatter
*/
public static function getInstance( Language $lang = null ) {
- global $wgMainCacheType;
-
$lang = $lang ?? MediaWikiServices::getInstance()->getContentLanguage();
- $cache = ObjectCache::getLocalServerInstance( $wgMainCacheType );
-
- static $dateFormatter = false;
- if ( !$dateFormatter ) {
- $dateFormatter = $cache->getWithSetCallback(
- $cache->makeKey( 'dateformatter', $lang->getCode() ),
- $cache::TTL_HOUR,
- function () use ( $lang ) {
- return new DateFormatter( $lang );
- }
- );
- }
-
- return $dateFormatter;
+ return MediaWikiServices::getInstance()->getDateFormatterFactory()->get( $lang );
}
/**
- * @param string $preference User preference
+ * @param string $preference User preference, must be one of "default",
+ * "dmy", "mdy", "ymd" or "ISO 8601".
* @param string $text Text to reformat
- * @param array $options Array can contain 'linked' and/or 'match-whole'
+ * @param array $options Ignored. Since 1.33, 'match-whole' is implied, and
+ * 'linked' has been removed.
*
* @return string
*/
- public function reformat( $preference, $text, $options = [ 'linked' ] ) {
- $linked = in_array( 'linked', $options );
- $match_whole = in_array( 'match-whole', $options );
-
- if ( isset( $this->preferences[$preference] ) ) {
- $preference = $this->preferences[$preference];
+ public function reformat( $preference, $text, $options = [] ) {
+ if ( isset( $this->preferenceIDs[$preference] ) ) {
+ $preference = $this->preferenceIDs[$preference];
} else {
$preference = self::NONE;
}
- for ( $i = 1; $i <= self::LAST; $i++ ) {
- $this->mSource = $i;
- if ( isset( $this->rules[$preference][$i] ) ) {
+ for ( $source = 1; $source <= self::LAST; $source++ ) {
+ if ( isset( $this->rules[$preference][$source] ) ) {
# Specific rules
- $this->mTarget = $this->rules[$preference][$i];
- } elseif ( isset( $this->rules[self::ALL][$i] ) ) {
+ $target = $this->rules[$preference][$source];
+ } elseif ( isset( $this->rules[self::ALL][$source] ) ) {
# General rules
- $this->mTarget = $this->rules[self::ALL][$i];
+ $target = $this->rules[self::ALL][$source];
} elseif ( $preference ) {
# User preference
- $this->mTarget = $preference;
+ $target = $preference;
} else {
# Default
- $this->mTarget = $i;
- }
- $regex = $this->regexes[$i];
-
- // Horrible hack
- if ( !$linked ) {
- $regex = str_replace( [ '\[\[', '\]\]' ], '', $regex );
- }
-
- if ( $match_whole ) {
- // Let's hope this works
- $regex = preg_replace( '!^/!', '/^', $regex );
- $regex = str_replace( $this->regexTrail,
- '$' . $this->regexTrail, $regex );
- }
-
- // Another horrible hack
- $this->mLinked = $linked;
- $text = preg_replace_callback( $regex, [ $this, 'replace' ], $text );
- unset( $this->mLinked );
- }
- return $text;
- }
-
- /**
- * Regexp replacement callback
- *
- * @param array $matches
- * @return string
- */
- private function replace( $matches ) {
- # Extract information from $matches
- $linked = $this->mLinked ?? true;
-
- $bits = [];
- $key = $this->keys[$this->mSource];
- $keyLength = strlen( $key );
- for ( $p = 0; $p < $keyLength; $p++ ) {
- if ( $key[$p] != ' ' ) {
- $bits[$key[$p]] = $matches[$p + 1];
+ $target = $source;
}
- }
-
- return $this->formatDate( $bits, $matches[0], $linked );
- }
+ $regex = $this->regexes[$source];
- /**
- * @param array $bits
- * @param string $orig Original input string, to be returned
- * on formatting failure.
- * @param bool $link
- * @return string
- */
- private function formatDate( $bits, $orig, $link = true ) {
- $format = $this->targets[$this->mTarget];
-
- if ( !$link ) {
- // strip piped links
- $format = preg_replace( '/\[\[[^|]+\|([^\]]+)\]\]/', '$1', $format );
- // strip remaining links
- $format = str_replace( [ '[[', ']]' ], '', $format );
- }
-
- # Construct new date
- $text = '';
- $fail = false;
-
- // Pre-generate y/Y stuff because we need the year for the <span> title.
- if ( !isset( $bits['y'] ) && isset( $bits['Y'] ) ) {
- $bits['y'] = $this->makeIsoYear( $bits['Y'] );
- }
- if ( !isset( $bits['Y'] ) && isset( $bits['y'] ) ) {
- $bits['Y'] = $this->makeNormalYear( $bits['y'] );
- }
-
- if ( !isset( $bits['m'] ) ) {
- $m = $this->makeIsoMonth( $bits['F'] );
- if ( !$m || $m == '00' ) {
- $fail = true;
- } else {
- $bits['m'] = $m;
- }
- }
+ $text = preg_replace_callback( $regex,
+ function ( $match ) use ( $target ) {
+ $format = $this->targetFormats[$target];
- if ( !isset( $bits['d'] ) ) {
- $bits['d'] = sprintf( '%02d', $bits['j'] );
- }
+ $text = '';
- $formatLength = strlen( $format );
- for ( $p = 0; $p < $formatLength; $p++ ) {
- $char = $format[$p];
- switch ( $char ) {
- case 'd': # ISO day of month
- $text .= $bits['d'];
- break;
- case 'm': # ISO month
- $text .= $bits['m'];
- break;
- case 'y': # ISO year
- $text .= $bits['y'];
- break;
- case 'j': # ordinary day of month
- if ( !isset( $bits['j'] ) ) {
- $text .= intval( $bits['d'] );
- } else {
- $text .= $bits['j'];
+ // Pre-generate y/Y stuff because we need the year for the <span> title.
+ if ( !isset( $match['isoYear'] ) && isset( $match['year'] ) ) {
+ $match['isoYear'] = $this->makeIsoYear( $match['year'] );
+ }
+ if ( !isset( $match['year'] ) && isset( $match['isoYear'] ) ) {
+ $match['year'] = $this->makeNormalYear( $match['isoYear'] );
}
- break;
- case 'F': # long month
- if ( !isset( $bits['F'] ) ) {
- $m = intval( $bits['m'] );
- if ( $m > 12 || $m < 1 ) {
- $fail = true;
+
+ if ( !isset( $match['isoMonth'] ) ) {
+ $m = $this->makeIsoMonth( $match['monthName'] );
+ if ( $m === false ) {
+ // Fail
+ return $match[0];
} else {
- $text .= $this->lang->getMonthName( $m );
+ $match['isoMonth'] = $m;
}
- } else {
- $text .= ucfirst( $bits['F'] );
}
- break;
- case 'Y': # ordinary (optional BC) year
- $text .= $bits['Y'];
- break;
- default:
- $text .= $char;
- }
- }
- if ( $fail ) {
- // This occurs when parsing a date with day or month outside the bounds
- // of possibilities.
- $text = $orig;
- }
- $isoBits = [];
- if ( isset( $bits['y'] ) ) {
- $isoBits[] = $bits['y'];
- }
- $isoBits[] = $bits['m'];
- $isoBits[] = $bits['d'];
- $isoDate = implode( '-', $isoBits );
+ if ( !isset( $match['isoDay'] ) ) {
+ $match['isoDay'] = sprintf( '%02d', $match['day'] );
+ }
- // Output is not strictly HTML (it's wikitext), but <span> is whitelisted.
- $text = Html::rawElement( 'span',
- [ 'class' => 'mw-formatted-date', 'title' => $isoDate ], $text );
+ $formatLength = strlen( $format );
+ for ( $p = 0; $p < $formatLength; $p++ ) {
+ $char = $format[$p];
+ switch ( $char ) {
+ case 'd': // ISO day of month
+ $text .= $match['isoDay'];
+ break;
+ case 'm': // ISO month
+ $text .= $match['isoMonth'];
+ break;
+ case 'y': // ISO year
+ $text .= $match['isoYear'];
+ break;
+ case 'j': // ordinary day of month
+ if ( !isset( $match['day'] ) ) {
+ $text .= intval( $match['isoDay'] );
+ } else {
+ $text .= $match['day'];
+ }
+ break;
+ case 'F': // long month
+ $m = intval( $match['isoMonth'] );
+ if ( $m > 12 || $m < 1 ) {
+ // Fail
+ return $match[0];
+ } else {
+ $text .= $this->monthNames[$m];
+ }
+ break;
+ case 'Y': // ordinary (optional BC) year
+ $text .= $match['year'];
+ break;
+ default:
+ $text .= $char;
+ }
+ }
- return $text;
- }
+ $isoBits = [];
+ if ( isset( $match['isoYear'] ) ) {
+ $isoBits[] = $match['isoYear'];
+ }
+ $isoBits[] = $match['isoMonth'];
+ $isoBits[] = $match['isoDay'];
+ $isoDate = implode( '-', $isoBits );
- /**
- * Return a regex that can be used to find month names in string
- * @return string regex to find the months with
- */
- private function getMonthRegex() {
- $names = [];
- for ( $i = 1; $i <= 12; $i++ ) {
- $names[] = $this->lang->getMonthName( $i );
- $names[] = $this->lang->getMonthAbbreviation( $i );
+ // Output is not strictly HTML (it's wikitext), but <span> is whitelisted.
+ $text = Html::rawElement( 'span',
+ [ 'class' => 'mw-formatted-date', 'title' => $isoDate ], $text );
+
+ return $text;
+ }, $text
+ );
}
- return implode( '|', $names );
+ return $text;
}
/**
* Makes an ISO month, e.g. 02, from a month name
* @param string $monthName Month name
- * @return string ISO month name
+ * @return string|false ISO month name, or false if the input was invalid
*/
private function makeIsoMonth( $monthName ) {
- $n = $this->xMonths[$this->lang->lc( $monthName )];
- return sprintf( '%02d', $n );
+ $isoMonth = $this->xMonths[mb_strtolower( $monthName )] ?? false;
+ if ( $isoMonth === false ) {
+ return false;
+ }
+ return sprintf( '%02d', $isoMonth );
}
/**
* @return string ISO year name
*/
private function makeIsoYear( $year ) {
- # Assumes the year is in a nice format, as enforced by the regex
+ // Assumes the year is in a nice format, as enforced by the regex
if ( substr( $year, -2 ) == 'BC' ) {
$num = intval( substr( $year, 0, -3 ) ) - 1;
- # PHP bug note: sprintf( "%04d", -1 ) fails poorly
+ // PHP bug note: sprintf( "%04d", -1 ) fails poorly
$text = sprintf( '-%04d', $num );
-
} else {
$text = sprintf( '%04d', $year );
}
}
/**
- * Make a year one from an ISO year, for instance: '400 BC' from '-0399'.
+ * Make a year from an ISO year, for instance: '400 BC' from '-0399'.
* @param string $iso ISO year
* @return int|string int representing year number in case of AD dates, or string containing
* year number and 'BC' at the end otherwise.
--- /dev/null
+<?php
+
+class DateFormatterFactory {
+ /** @var DateFormatter[] */
+ private $instances;
+
+ /**
+ * @param Language $lang
+ * @return DateFormatter
+ */
+ public function get( Language $lang ) {
+ $code = $lang->getCode();
+ if ( !isset( $this->instances[$code] ) ) {
+ $this->instances[$code] = new DateFormatter( $lang );
+ }
+ return $this->instances[$code];
+ }
+}
/** @var LinkRendererFactory */
private $linkRendererFactory;
+ /** @var NamespaceInfo */
+ private $nsInfo;
+
/**
* @param array $parserConf See $wgParserConf documentation
* @param MagicWordFactory|null $magicWordFactory
* @param SpecialPageFactory|null $spFactory
* @param Config|null $siteConfig
* @param LinkRendererFactory|null $linkRendererFactory
+ * @param NamespaceInfo|null $nsInfo
*/
public function __construct(
array $parserConf = [], MagicWordFactory $magicWordFactory = null,
Language $contLang = null, ParserFactory $factory = null, $urlProtocols = null,
SpecialPageFactory $spFactory = null, Config $siteConfig = null,
- LinkRendererFactory $linkRendererFactory = null
+ LinkRendererFactory $linkRendererFactory = null,
+ NamespaceInfo $nsInfo = null
) {
$this->mConf = $parserConf;
$this->mUrlProtocols = $urlProtocols ?? wfUrlProtocols();
$this->factory = $factory ?? $services->getParserFactory();
$this->specialPageFactory = $spFactory ?? $services->getSpecialPageFactory();
- $this->siteConfig = $siteConfig ?? MediaWikiServices::getInstance()->getMainConfig();
-
+ $this->siteConfig = $siteConfig ?? $services->getMainConfig();
$this->linkRendererFactory =
- $linkRendererFactory ?? MediaWikiServices::getInstance()->getLinkRendererFactory();
+ $linkRendererFactory ?? $services->getLinkRendererFactory();
+ $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo();
}
/**
*/
public function areSubpagesAllowed() {
# Some namespaces don't allow subpages
- return MWNamespace::hasSubpages( $this->mTitle->getNamespace() );
+ return $this->nsInfo->hasSubpages( $this->mTitle->getNamespace() );
}
/**
$this->siteConfig->get( 'MiserMode' ) &&
!$this->mOptions->getInterfaceMessage() &&
// @TODO: disallow this word on all namespaces
- MWNamespace::isContent( $this->mTitle->getNamespace() )
+ $this->nsInfo->isContent( $this->mTitle->getNamespace() )
) {
return $this->mRevisionId ? '-' : '';
};
);
}
}
- } elseif ( MWNamespace::isNonincludable( $title->getNamespace() ) ) {
+ } elseif ( $this->nsInfo->isNonincludable( $title->getNamespace() ) ) {
$found = false; # access denied
wfDebug( __METHOD__ . ": template inclusion denied for " .
$title->getPrefixedDBkey() . "\n" );
* @ingroup Parser
*/
use MediaWiki\Linker\LinkRendererFactory;
-
+use MediaWiki\MediaWikiServices;
use MediaWiki\Special\SpecialPageFactory;
/**
/** @var LinkRendererFactory */
private $linkRendererFactory;
+ /** @var NamespaceInfo */
+ private $nsInfo;
+
/**
* @param array $parserConf See $wgParserConf documentation
* @param MagicWordFactory $magicWordFactory
* @param SpecialPageFactory $spFactory
* @param Config $siteConfig
* @param LinkRendererFactory $linkRendererFactory
+ * @param NamespaceInfo|null $nsInfo
* @since 1.32
*/
public function __construct(
array $parserConf, MagicWordFactory $magicWordFactory, Language $contLang, $urlProtocols,
- SpecialPageFactory $spFactory, Config $siteConfig, LinkRendererFactory $linkRendererFactory
+ SpecialPageFactory $spFactory, Config $siteConfig,
+ LinkRendererFactory $linkRendererFactory, NamespaceInfo $nsInfo = null
) {
+ if ( !$nsInfo ) {
+ wfDeprecated( __METHOD__ . ' with no NamespaceInfo argument', '1.34' );
+ $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
+ }
$this->parserConf = $parserConf;
$this->magicWordFactory = $magicWordFactory;
$this->contLang = $contLang;
$this->specialPageFactory = $spFactory;
$this->siteConfig = $siteConfig;
$this->linkRendererFactory = $linkRendererFactory;
+ $this->nsInfo = $nsInfo;
}
/**
public function create() : Parser {
return new Parser( $this->parserConf, $this->magicWordFactory, $this->contLang, $this,
$this->urlProtocols, $this->specialPageFactory, $this->siteConfig,
- $this->linkRendererFactory );
+ $this->linkRendererFactory, $this->nsInfo );
}
}
* @file
* @ingroup Search
*/
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
/**
* Search engine hook base class for Postgres
$top = $res->fetchRow()[0];
$this->searchTerms = [];
+ $slotRoleStore = MediaWikiServices::getInstance()->getSlotRoleStore();
if ( $top === "" ) { # # e.g. if only stopwords are used XXX return something better
$query = "SELECT page_id, page_namespace, page_title, 0 AS score " .
- "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
- "AND r.rev_text_id = c.old_id AND 1=0";
+ "FROM page p, revision r, slots s, content c, pagecontent pc " .
+ "WHERE p.page_latest = r.rev_id " .
+ "AND s.slot_revision_id = r.rev_id " .
+ "AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+ "AND c.content_id = s.slot_content_id " .
+ "AND pc.old_id = substring( c.content_address from '^tt:([0-9]+)$' )::int " .
+ "AND 1=0";
} else {
$m = [];
if ( preg_match_all( "/'([^']+)'/", $top, $m, PREG_SET_ORDER ) ) {
$query = "SELECT page_id, page_namespace, page_title, " .
"ts_rank($fulltext, to_tsquery($searchstring), 5) AS score " .
- "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
- "AND r.rev_text_id = c.old_id AND $fulltext @@ to_tsquery($searchstring)";
+ "FROM page p, revision r, slots s, content c, pagecontent pc " .
+ "WHERE p.page_latest = r.rev_id " .
+ "AND s.slot_revision_id = r.rev_id " .
+ "AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+ "AND c.content_id = s.slot_content_id " .
+ "AND pc.old_id = substring( c.content_address from '^tt:([0-9]+)$' )::int " .
+ "AND $fulltext @@ to_tsquery($searchstring)";
}
-
# # Namespaces - defaults to 0
if ( !is_null( $this->namespaces ) ) { // null -> search all
if ( count( $this->namespaces ) < 1 ) {
function update( $pageid, $title, $text ) {
# # We don't want to index older revisions
- $sql = "UPDATE pagecontent SET textvector = NULL WHERE textvector IS NOT NULL and old_id IN " .
- "(SELECT DISTINCT rev_text_id FROM revision WHERE rev_page = " . intval( $pageid ) .
- " ORDER BY rev_text_id DESC OFFSET 1)";
+ $slotRoleStore = MediaWikiServices::getInstance()->getSlotRoleStore();
+ $sql = "UPDATE pagecontent SET textvector = NULL " .
+ "WHERE textvector IS NOT NULL " .
+ "AND old_id IN " .
+ "(SELECT DISTINCT substring( c.content_address from '^tt:([0-9]+)$' )::int AS old_rev_text_id " .
+ " FROM content c, slots s, revision r " .
+ " WHERE r.rev_page = $pageid " .
+ " AND s.slot_revision_id = r.rev_id " .
+ " AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+ " AND c.content_id = s.slot_content_id " .
+ " ORDER BY old_rev_text_id DESC OFFSET 1)";
$this->db->query( $sql );
return true;
}
use SpecialPage;
use Title;
use User;
+use Wikimedia\Assert\Assert;
/**
* Factory for handling the special page list and generating SpecialPage objects.
private $aliases;
/** @var Config */
- private $config;
+ private $options;
/** @var Language */
private $contLang;
/**
- * @param Config $config
+ * TODO Make this a const when HHVM support is dropped (T192166)
+ *
+ * @var array
+ * @since 1.33
+ * */
+ public static $constructorOptions = [
+ 'ContentHandlerUseDB',
+ 'DisableInternalSearch',
+ 'EmailAuthentication',
+ 'EnableEmail',
+ 'EnableJavaScriptTest',
+ 'PageLanguageUseDB',
+ 'SpecialPages',
+ ];
+
+ /**
+ * @param array $options
* @param Language $contLang
*/
- public function __construct( Config $config, Language $contLang ) {
- $this->config = $config;
+ public function __construct( array $options, Language $contLang ) {
+ Assert::parameter( count( $options ) === count( self::$constructorOptions ) &&
+ !array_diff( self::$constructorOptions, array_keys( $options ) ),
+ '$options', 'Wrong set of options present' );
+ $this->options = $options;
$this->contLang = $contLang;
}
if ( !is_array( $this->list ) ) {
$this->list = self::$coreList;
- if ( !$this->config->get( 'DisableInternalSearch' ) ) {
+ if ( !$this->options['DisableInternalSearch'] ) {
$this->list['Search'] = \SpecialSearch::class;
}
- if ( $this->config->get( 'EmailAuthentication' ) ) {
+ if ( $this->options['EmailAuthentication'] ) {
$this->list['Confirmemail'] = \EmailConfirmation::class;
$this->list['Invalidateemail'] = \EmailInvalidation::class;
}
- if ( $this->config->get( 'EnableEmail' ) ) {
+ if ( $this->options['EnableEmail'] ) {
$this->list['ChangeEmail'] = \SpecialChangeEmail::class;
}
- if ( $this->config->get( 'EnableJavaScriptTest' ) ) {
+ if ( $this->options['EnableJavaScriptTest'] ) {
$this->list['JavaScriptTest'] = \SpecialJavaScriptTest::class;
}
- if ( $this->config->get( 'PageLanguageUseDB' ) ) {
+ if ( $this->options['PageLanguageUseDB'] ) {
$this->list['PageLanguage'] = \SpecialPageLanguage::class;
}
- if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+ if ( $this->options['ContentHandlerUseDB'] ) {
$this->list['ChangeContentModel'] = \SpecialChangeContentModel::class;
}
// Add extension special pages
- $this->list = array_merge( $this->list, $this->config->get( 'SpecialPages' ) );
+ $this->list = array_merge( $this->list, $this->options['SpecialPages'] );
// This hook can be used to disable unwanted core special pages
// or conditionally register special pages.
* @return array
*/
protected function getFormFields() {
- global $wgBlockAllowsUTEdit;
+ $conf = $this->getConfig();
+ $enablePartialBlocks = $conf->get( 'EnablePartialBlocks' );
+ $blockAllowsUTEdit = $conf->get( 'BlockAllowsUTEdit' );
$this->getOutput()->enableOOUI();
$suggestedDurations = self::getSuggestedDurations();
- $conf = $this->getConfig();
- $enablePartialBlocks = $conf->get( 'EnablePartialBlocks' );
-
$a = [];
$a['Target'] = [
];
}
- if ( $wgBlockAllowsUTEdit ) {
+ if ( $blockAllowsUTEdit ) {
$a['DisableUTEdit'] = [
'type' => 'check',
'label-message' => 'ipb-disableusertalk',
$scalerThumbUrl = $scalerBaseUrl . '/' . $file->getUrlRel() .
'/' . rawurlencode( $scalerThumbName );
+ // If a thumb proxy is set up for the repo, we favor that, as that will
+ // keep the request internal
+ $thumbProxyUrl = $file->getRepo()->getThumbProxyUrl();
+
+ if ( strlen( $thumbProxyUrl ) ) {
+ $scalerThumbUrl = $thumbProxyUrl . 'temp/' . $file->getUrlRel() .
+ '/' . rawurlencode( $scalerThumbName );
+ }
+
// make an http request based on wgUploadStashScalerBaseUrl to lazy-create
// a thumbnail
$httpOptions = [
'timeout' => 5 // T90599 attempt to time out cleanly
];
$req = MWHttpRequest::factory( $scalerThumbUrl, $httpOptions, __METHOD__ );
+
+ $secret = $file->getRepo()->getThumbProxySecret();
+
+ // Pass a secret key shared with the proxied service if any
+ if ( strlen( $secret ) ) {
+ $req->setHeader( 'X-Swift-Secret', $secret );
+ }
+
$status = $req->execute();
if ( !$status->isOK() ) {
$errors = $status->getErrorsArray();
'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
+ 'Kunal Mehta', 'James D. Forrester', 'Brian Wolff', 'Adam Shorland',
$othersLink, $translatorsLink
];
--- /dev/null
+<?php
+/**
+ * Provide things related to namespaces.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of
+ * them based on index. The textual names of the namespaces are handled by Language.php.
+ *
+ * @since 1.33
+ */
+class NamespaceInfo {
+
+ /**
+ * These namespaces should always be first-letter capitalized, now and
+ * forevermore. Historically, they could've probably been lowercased too,
+ * but some things are just too ingrained now. :)
+ */
+ private $alwaysCapitalizedNamespaces = [ NS_SPECIAL, NS_USER, NS_MEDIAWIKI ];
+
+ /** @var string[]|null Canonical namespaces cache */
+ private $canonicalNamespaces = null;
+
+ /** @var array|false Canonical namespaces index cache */
+ private $namespaceIndexes = false;
+
+ /** @var int[]|null Valid namespaces cache */
+ private $validNamespaces = null;
+
+ /** @var Config */
+ private $config;
+
+ /**
+ * @param Config $config
+ */
+ public function __construct( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * Throw an exception when trying to get the subject or talk page
+ * for a given namespace where it does not make sense.
+ * Special namespaces are defined in includes/Defines.php and have
+ * a value below 0 (ex: NS_SPECIAL = -1 , NS_MEDIA = -2)
+ *
+ * @param int $index
+ * @param string $method
+ *
+ * @throws MWException
+ * @return bool
+ */
+ private function isMethodValidFor( $index, $method ) {
+ if ( $index < NS_MAIN ) {
+ throw new MWException( "$method does not make any sense for given namespace $index" );
+ }
+ return true;
+ }
+
+ /**
+ * Can pages in the given namespace be moved?
+ *
+ * @param int $index Namespace index
+ * @return bool
+ */
+ public function isMovable( $index ) {
+ $result = !( $index < NS_MAIN ||
+ ( $index == NS_FILE && !$this->config->get( 'AllowImageMoving' ) ) );
+
+ /**
+ * @since 1.20
+ */
+ Hooks::run( 'NamespaceIsMovable', [ $index, &$result ] );
+
+ return $result;
+ }
+
+ /**
+ * Is the given namespace is a subject (non-talk) namespace?
+ *
+ * @param int $index Namespace index
+ * @return bool
+ */
+ public function isSubject( $index ) {
+ return !$this->isTalk( $index );
+ }
+
+ /**
+ * Is the given namespace a talk namespace?
+ *
+ * @param int $index Namespace index
+ * @return bool
+ */
+ public function isTalk( $index ) {
+ return $index > NS_MAIN
+ && $index % 2;
+ }
+
+ /**
+ * Get the talk namespace index for a given namespace
+ *
+ * @param int $index Namespace index
+ * @return int
+ */
+ public function getTalk( $index ) {
+ $this->isMethodValidFor( $index, __METHOD__ );
+ return $this->isTalk( $index )
+ ? $index
+ : $index + 1;
+ }
+
+ /**
+ * Get the subject namespace index for a given namespace
+ * Special namespaces (NS_MEDIA, NS_SPECIAL) are always the subject.
+ *
+ * @param int $index Namespace index
+ * @return int
+ */
+ public function getSubject( $index ) {
+ # Handle special namespaces
+ if ( $index < NS_MAIN ) {
+ return $index;
+ }
+
+ return $this->isTalk( $index )
+ ? $index - 1
+ : $index;
+ }
+
+ /**
+ * Get the associated namespace.
+ * For talk namespaces, returns the subject (non-talk) namespace
+ * For subject (non-talk) namespaces, returns the talk namespace
+ *
+ * @param int $index Namespace index
+ * @return int|null If no associated namespace could be found
+ */
+ public function getAssociated( $index ) {
+ $this->isMethodValidFor( $index, __METHOD__ );
+
+ if ( $this->isSubject( $index ) ) {
+ return $this->getTalk( $index );
+ } elseif ( $this->isTalk( $index ) ) {
+ return $this->getSubject( $index );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns whether the specified namespace exists
+ *
+ * @param int $index
+ *
+ * @return bool
+ */
+ public function exists( $index ) {
+ $nslist = $this->getCanonicalNamespaces();
+ return isset( $nslist[$index] );
+ }
+
+ /**
+ * Returns whether the specified namespaces are the same namespace
+ *
+ * @note It's possible that in the future we may start using something
+ * other than just namespace indexes. Under that circumstance making use
+ * of this function rather than directly doing comparison will make
+ * sure that code will not potentially break.
+ *
+ * @param int $ns1 The first namespace index
+ * @param int $ns2 The second namespace index
+ *
+ * @return bool
+ */
+ public function equals( $ns1, $ns2 ) {
+ return $ns1 == $ns2;
+ }
+
+ /**
+ * Returns whether the specified namespaces share the same subject.
+ * eg: NS_USER and NS_USER wil return true, as well
+ * NS_USER and NS_USER_TALK will return true.
+ *
+ * @param int $ns1 The first namespace index
+ * @param int $ns2 The second namespace index
+ *
+ * @return bool
+ */
+ public function subjectEquals( $ns1, $ns2 ) {
+ return $this->getSubject( $ns1 ) == $this->getSubject( $ns2 );
+ }
+
+ /**
+ * Returns array of all defined namespaces with their canonical
+ * (English) names.
+ *
+ * @return array
+ */
+ public function getCanonicalNamespaces() {
+ if ( $this->canonicalNamespaces === null ) {
+ $this->canonicalNamespaces =
+ [ NS_MAIN => '' ] + $this->config->get( 'CanonicalNamespaceNames' );
+ $this->canonicalNamespaces +=
+ ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' );
+ if ( is_array( $this->config->get( 'ExtraNamespaces' ) ) ) {
+ $this->canonicalNamespaces += $this->config->get( 'ExtraNamespaces' );
+ }
+ Hooks::run( 'CanonicalNamespaces', [ &$this->canonicalNamespaces ] );
+ }
+ return $this->canonicalNamespaces;
+ }
+
+ /**
+ * Returns the canonical (English) name for a given index
+ *
+ * @param int $index Namespace index
+ * @return string|bool If no canonical definition.
+ */
+ public function getCanonicalName( $index ) {
+ $nslist = $this->getCanonicalNamespaces();
+ return $nslist[$index] ?? false;
+ }
+
+ /**
+ * Returns the index for a given canonical name, or NULL
+ * The input *must* be converted to lower case first
+ *
+ * @param string $name Namespace name
+ * @return int
+ */
+ public function getCanonicalIndex( $name ) {
+ if ( $this->namespaceIndexes === false ) {
+ $this->namespaceIndexes = [];
+ foreach ( $this->getCanonicalNamespaces() as $i => $text ) {
+ $this->namespaceIndexes[strtolower( $text )] = $i;
+ }
+ }
+ if ( array_key_exists( $name, $this->namespaceIndexes ) ) {
+ return $this->namespaceIndexes[$name];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns an array of the namespaces (by integer id) that exist on the
+ * wiki. Used primarily by the api in help documentation.
+ * @return array
+ */
+ public function getValidNamespaces() {
+ if ( is_null( $this->validNamespaces ) ) {
+ foreach ( array_keys( $this->getCanonicalNamespaces() ) as $ns ) {
+ if ( $ns >= 0 ) {
+ $this->validNamespaces[] = $ns;
+ }
+ }
+ // T109137: sort numerically
+ sort( $this->validNamespaces, SORT_NUMERIC );
+ }
+
+ return $this->validNamespaces;
+ }
+
+ /*
+
+ /**
+ * Does this namespace ever have a talk namespace?
+ *
+ * @param int $index Namespace ID
+ * @return bool True if this namespace either is or has a corresponding talk namespace.
+ */
+ public function hasTalkNamespace( $index ) {
+ return $index >= NS_MAIN;
+ }
+
+ /**
+ * Does this namespace contain content, for the purposes of calculating
+ * statistics, etc?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public function isContent( $index ) {
+ return $index == NS_MAIN || in_array( $index, $this->config->get( 'ContentNamespaces' ) );
+ }
+
+ /**
+ * Might pages in this namespace require the use of the Signature button on
+ * the edit toolbar?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public function wantSignatures( $index ) {
+ return $this->isTalk( $index ) ||
+ in_array( $index, $this->config->get( 'ExtraSignatureNamespaces' ) );
+ }
+
+ /**
+ * Can pages in a namespace be watched?
+ *
+ * @param int $index
+ * @return bool
+ */
+ public function isWatchable( $index ) {
+ return $index >= NS_MAIN;
+ }
+
+ /**
+ * Does the namespace allow subpages?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public function hasSubpages( $index ) {
+ return !empty( $this->config->get( 'NamespacesWithSubpages' )[$index] );
+ }
+
+ /**
+ * Get a list of all namespace indices which are considered to contain content
+ * @return array Array of namespace indices
+ */
+ public function getContentNamespaces() {
+ $contentNamespaces = $this->config->get( 'ContentNamespaces' );
+ if ( !is_array( $contentNamespaces ) || $contentNamespaces === [] ) {
+ return [ NS_MAIN ];
+ } elseif ( !in_array( NS_MAIN, $contentNamespaces ) ) {
+ // always force NS_MAIN to be part of array (to match the algorithm used by isContent)
+ return array_merge( [ NS_MAIN ], $contentNamespaces );
+ } else {
+ return $contentNamespaces;
+ }
+ }
+
+ /**
+ * List all namespace indices which are considered subject, aka not a talk
+ * or special namespace. See also NamespaceInfo::isSubject
+ *
+ * @return array Array of namespace indices
+ */
+ public function getSubjectNamespaces() {
+ return array_filter(
+ $this->getValidNamespaces(),
+ [ $this, 'isSubject' ]
+ );
+ }
+
+ /**
+ * List all namespace indices which are considered talks, aka not a subject
+ * or special namespace. See also NamespaceInfo::isTalk
+ *
+ * @return array Array of namespace indices
+ */
+ public function getTalkNamespaces() {
+ return array_filter(
+ $this->getValidNamespaces(),
+ [ $this, 'isTalk' ]
+ );
+ }
+
+ /**
+ * Is the namespace first-letter capitalized?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public function isCapitalized( $index ) {
+ // Turn NS_MEDIA into NS_FILE
+ $index = $index === NS_MEDIA ? NS_FILE : $index;
+
+ // Make sure to get the subject of our namespace
+ $index = $this->getSubject( $index );
+
+ // Some namespaces are special and should always be upper case
+ if ( in_array( $index, $this->alwaysCapitalizedNamespaces ) ) {
+ return true;
+ }
+ $overrides = $this->config->get( 'CapitalLinkOverrides' );
+ if ( isset( $overrides[$index] ) ) {
+ // CapitalLinkOverrides is explicitly set
+ return $overrides[$index];
+ }
+ // Default to the global setting
+ return $this->config->get( 'CapitalLinks' );
+ }
+
+ /**
+ * Does the namespace (potentially) have different aliases for different
+ * genders. Not all languages make a distinction here.
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public function hasGenderDistinction( $index ) {
+ return $index == NS_USER || $index == NS_USER_TALK;
+ }
+
+ /**
+ * It is not possible to use pages from this namespace as template?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public function isNonincludable( $index ) {
+ $namespaces = $this->config->get( 'NonincludableNamespaces' );
+ return $namespaces && in_array( $index, $namespaces );
+ }
+
+ /**
+ * Get the default content model for a namespace
+ * This does not mean that all pages in that namespace have the model
+ *
+ * @note To determine the default model for a new page's main slot, or any slot in general,
+ * use SlotRoleHandler::getDefaultModel() together with SlotRoleRegistry::getRoleHandler().
+ *
+ * @param int $index Index to check
+ * @return null|string Default model name for the given namespace, if set
+ */
+ public function getNamespaceContentModel( $index ) {
+ return $this->config->get( 'NamespaceContentModels' )[$index] ?? null;
+ }
+
+ /**
+ * Determine which restriction levels it makes sense to use in a namespace,
+ * optionally filtered by a user's rights.
+ *
+ * @param int $index Index to check
+ * @param User|null $user User to check
+ * @return array
+ */
+ public function getRestrictionLevels( $index, User $user = null ) {
+ if ( !isset( $this->config->get( 'NamespaceProtection' )[$index] ) ) {
+ // All levels are valid if there's no namespace restriction.
+ // But still filter by user, if necessary
+ $levels = $this->config->get( 'RestrictionLevels' );
+ if ( $user ) {
+ $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
+ $right = $level;
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected'; // BC
+ }
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected'; // BC
+ }
+ return ( $right == '' || $user->isAllowed( $right ) );
+ } ) );
+ }
+ return $levels;
+ }
+
+ // First, get the list of groups that can edit this namespace.
+ $namespaceGroups = [];
+ $combine = 'array_merge';
+ foreach ( (array)$this->config->get( 'NamespaceProtection' )[$index] as $right ) {
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected'; // BC
+ }
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected'; // BC
+ }
+ if ( $right != '' ) {
+ $namespaceGroups = call_user_func( $combine, $namespaceGroups,
+ User::getGroupsWithPermission( $right ) );
+ $combine = 'array_intersect';
+ }
+ }
+
+ // Now, keep only those restriction levels where there is at least one
+ // group that can edit the namespace but would be blocked by the
+ // restriction.
+ $usableLevels = [ '' ];
+ foreach ( $this->config->get( 'RestrictionLevels' ) as $level ) {
+ $right = $level;
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected'; // BC
+ }
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected'; // BC
+ }
+ if ( $right != '' && ( !$user || $user->isAllowed( $right ) ) &&
+ array_diff( $namespaceGroups, User::getGroupsWithPermission( $right ) )
+ ) {
+ $usableLevels[] = $level;
+ }
+ }
+
+ return $usableLevels;
+ }
+
+ /**
+ * Returns the link type to be used for categories.
+ *
+ * This determines which section of a category page titles
+ * in the namespace will appear within.
+ *
+ * @param int $index Namespace index
+ * @return string One of 'subcat', 'file', 'page'
+ */
+ public function getCategoryLinkType( $index ) {
+ $this->isMethodValidFor( $index, __METHOD__ );
+
+ if ( $index == NS_CATEGORY ) {
+ return 'subcat';
+ } elseif ( $index == NS_FILE ) {
+ return 'file';
+ } else {
+ return 'page';
+ }
+ }
+}
}
// If the page is watched by the user (or may be watched), update the timestamp
- $job = new ClearWatchlistNotificationsJob(
- $user->getUserPage(),
- [ 'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time() ]
- );
+ $job = new ClearWatchlistNotificationsJob( [
+ 'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
+ ] );
// Try to run this post-send
// Calls DeferredUpdates::addCallableUpdate in normal operation
}
/**
- * @return array
+ * @return string
*/
function fallback8bitEncoding() {
return self::$dataCache->getItem( $this->mCode, 'fallback8bitEncoding' );
"accmailtext": "Unvióse a $2 una contraseña xenerada al debalu pal usuariu [[User talk:$1|$1]]. Pue camudase na páxina ''[[Special:ChangePassword|camudar contraseña]]'' depués d'aniciar sesión.",
"newarticle": "(Nuevu)",
"newarticletext": "Siguisti un enllaz a un artículu qu'inda nun esiste.\nPa crear la páxina, empecipia a escribir nel cuadru d'embaxo (mira la [$1 páxina d'ayuda] pa más información).\nSi llegasti equí por enquivocu, calca nel botón <strong>atrás</strong> del to restolador.",
- "anontalkpagetext": "----\n''Esta ye la páxina d'alderique pa un usuariu anónimu qu'inda nun creó una cuenta o que nun la usa.''\nPola mor d'ello ha usase la direición numbérica IP pa identificalu/la.\nTala IP pue compartise por varios usuarios.\nSi yes un usuariu anónimu y notes qu'hai comentarios irrelevantes empobinaos pa ti, por favor [[Special:CreateAccount|crea una cuenta]] o [[Special:UserLogin|anicia sesiín]] pa torgar futures confusiones con otros usuarios anónimos.",
+ "anontalkpagetext": "----\n<em>Esta ye la páxina d'alderique pa un usuariu anónimu qu'inda nun creó una cuenta o que nun la usa.</em>\nPola mor d'ello ha usase la direición numbérica IP como identificación.\nTala IP pué compartise por dellos usuarios.\nSi yes un usuariu anónimu y notes qu'hai comentarios ensin relevancia dirixíos pa ti, por favor [[Special:CreateAccount|crea una cuenta]] o [[Special:UserLogin|anicia sesión]] pa torgar futures confusiones con otros usuarios anónimos.",
"noarticletext": "Nestos momentos nun hai testu nesta páxina.\nPuedes [[Special:Search/{{PAGENAME}}|buscar esti títulu de páxina]] n'otres páxines,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} buscar los rexistros rellacionaos],\no [{{fullurl:{{FULLPAGENAME}}|action=edit}} crear esta páxina]</span>.",
"noarticletext-nopermission": "Nestos momentos nun hai testu nesta páxina.\nPuedes [[Special:Search/{{PAGENAME}}|buscar esti títulu de páxina]] n'otres páxines o <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} buscar los rexistros rellacionaos]</span>, pero nun tienes permisu pa crear esta páxina.",
"missing-revision": "La revisión #$1 de la páxina llamada \"{{FULLPAGENAME}}\" nun esiste.\n\nDe vezu la causa d'esto ye siguir un enllaz antiguu del historial a una páxina que se desanició.\nSe puen alcontrar más detalles nel [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} rexistru de desanicios].",
"delete-confirm": "Выдаліць «$1»",
"delete-legend": "Выдаліць",
"historywarning": "<strong>Папярэджаньне</strong>: старонка, якую Вы зьбіраецеся выдаліць, мае гісторыю з $1 {{PLURAL:$1|вэрсіі|вэрсіяў|вэрсіяў}}:",
- "historyaction-submit": "Паказаць",
+ "historyaction-submit": "Паказаць вэрсіі",
"confirmdeletetext": "Зараз Вы выдаліце старонку разам з усёй гісторыяй зьменаў.\nКалі ласка, пацьвердзіце, што Вы зьбіраецеся гэта зрабіць і што Вы разумееце ўсе наступствы, а таксама робіце гэта ў адпаведнасьці з [[{{MediaWiki:Policy-url}}|правіламі]].",
"actioncomplete": "Дзеяньне выкананае",
"actionfailed": "Дзеяньне ня выкананае",
"delete-confirm": "Smazání stránky „$1“",
"delete-legend": "Smazat",
"historywarning": "<strong>Varování:</strong> Stránka, kterou se chystáte smazat, má historii s $1 {{PLURAL:$1|revizí|revizemi}}:",
- "historyaction-submit": "Zobrazit",
+ "historyaction-submit": "Zobrazit revize",
"confirmdeletetext": "Chystáte se smazat stránku s celou její historií. Prosím potvrďte, že to opravdu chcete učinit, že si uvědomujete důsledky a že je to v souladu s [[{{MediaWiki:Policy-url}}|pravidly]].",
"actioncomplete": "Provedeno",
"actionfailed": "Operace se nezdařila",
"delete-confirm": "Löschen von „$1“",
"delete-legend": "Löschen",
"historywarning": "<strong>Achtung:</strong> Die Seite, die du löschen möchtest, hat eine Versionsgeschichte mit {{PLURAL:$1|einer Version|$1 Versionen}}:",
- "historyaction-submit": "Anzeigen",
+ "historyaction-submit": "Versionen anzeigen",
"confirmdeletetext": "Du bist dabei, eine Seite mit allen zugehörigen älteren Versionen zu löschen. Bitte bestätige dazu, dass du dir der Konsequenzen bewusst bist, und dass du in Übereinstimmung mit den [[{{MediaWiki:Policy-url}}|Richtlinien]] handelst.",
"actioncomplete": "Aktion beendet",
"actionfailed": "Aktion fehlgeschlagen",
"watchthis": "Na pele de seyr ke",
"savearticle": "Pele qeyd ke",
"savechanges": "Vurnayışan qeyd ke",
- "publishpage": "Pele neşr kerê",
- "publishchanges": "Vırnayışan qeyd ke",
+ "publishpage": "Pele qeyd ke",
+ "publishchanges": "Vurnayışan qeyd ke",
"savearticle-start": "Pele qeyd ke...",
"savechanges-start": "Vurnayışan qeyd ke...",
"publishpage-start": "Pele weşane...",
"page_first": "verên",
"page_last": "peyên",
"histlegend": "Ferqê weçinayışi: Qutiya versiyonan qandé têversanayış işaret ke u dest be ''enter''i ya zi gocega cêrêne rone.<br />\nCetwel: <strong>({{int:ferq}})</strong> = ferqê versiyonê peyêni, <strong>({{int:peyên}})</strong> = ferqê versiyonê verêni, <strong>{{int:q}}</strong> = vırnayışo werdiyo.",
- "history-fieldset-title": "Çım ra viyarnayışan cı geyre",
+ "history-fieldset-title": "Çımraviyarnayışan cı geyre",
"history-show-deleted": "Tenya çımraviyarnayışanê esterıteyan bımocne",
"histfirst": "Verênêr",
"histlast": "Peyênêr",
"delete-confirm": "\"$1\" bestere",
"delete-legend": "Bestere",
"historywarning": "'''Teme:''' Pela ke şıma esterenê tede yew viyarte be teqriben $1 {{PLURAL:$1|versiyon esto|versiyoni estê}}:",
- "historyaction-submit": "Bımocne",
+ "historyaction-submit": "Versiyonan bımocne",
"confirmdeletetext": "Tı ho yew pele u tarixê pele wederneno.\nTı ra rica keno, tı zani tı ho sekeno, tı zani neticeyanê eno wedarnayışi u tı zani tı ser [[{{MediaWiki:Policy-url}}|poliçe]] kar keno.",
"actioncomplete": "Kar bi temam",
"actionfailed": "kar nêbı",
"tooltip-ca-nstab-category": "Pela kategoriye bıvêne",
"tooltip-minoredit": "Ney vırnayışo werdi nışan bıkerê",
"tooltip-save": "Vurnayışanê xo qeyd ke",
- "tooltip-publish": "Vurnayışê xo neşr kerê",
+ "tooltip-publish": "Vurnayışê xo qeyd ke",
"tooltip-preview": "Vurnayışanê xo çım ra bıviyarnê. Qeydkerdış ra ver bıgurê cı!",
"tooltip-diff": "Kamci vırnayışê ke şıma nuştey sero kerdê, inan bıvênê.",
"tooltip-compareselectedversions": "Ena per de ferqê rewziyonan de dı weçinaya bıvinê",
"page_first": "first",
"page_last": "last",
"histlegend": "Diff selection: Mark the radio boxes of the revisions to compare and hit enter or the button at the bottom.<br />\nLegend: <strong>({{int:cur}})</strong> = difference with latest revision, <strong>({{int:last}})</strong> = difference with preceding revision, <strong>{{int:minoreditletter}}</strong> = minor edit.",
- "history-fieldset-title": "Search for revisions",
+ "history-fieldset-title": "Filter revisions",
"history-show-deleted": "Revision deleted only",
"history_copyright": "-",
"histfirst": "oldest",
"mycontris": "Contribuciones",
"anoncontribs": "Contribuciones",
"contribsub2": "Para {{GENDER:$3|$1}} ($2)",
+ "contributions-subtitle": "Para {{GENDER:$3|$1}} ($2)",
"contributions-userdoesnotexist": "La cuenta de usuario «$1» no está registrada.",
"negative-namespace-not-supported": "Los espacios de nombres con valores negativos no están permitidos",
"nocontribs": "No se encontraron cambios que cumplieran estos criterios.",
"nstab-category": "Luokka",
"mainpage-nstab": "Etusivu",
"nosuchaction": "Toimintoa ei ole olemassa",
- "nosuchactiontext": "URL:ssä määritelty toiminto ei ole kelvollinen.\nOlet saattanut kirjoittaa URL:in väärin tai olet seurannut virheellistä linkkiä.\nKyseessä voi myös mahdollisesti olla virhe sivuston {{SITENAME}} käyttämässä ohjelmistossa.",
+ "nosuchactiontext": "URL:ssä määritelty toiminto ei ole kelvollinen.\nOlet saattanut kirjoittaa URL:in väärin tai olet seurannut virheellistä linkkiä.\nKyseessä voi myös mahdollisesti olla virhe {{GRAMMAR:genitive|{{SITENAME}}}} käyttämässä ohjelmistossa.",
"nosuchspecialpage": "Kyseistä toimintosivua ei ole",
"nospecialpagetext": "<strong>Ohjelmisto ei tunnista pyytämääsi toimintosivua.</strong>\n\nLuettelo toimintosivuista löytyy sivulta [[Special:SpecialPages|{{int:specialpages}}]].",
"error": "Virhe",
"passwordreset-email": "Sähköpostiosoite:",
"passwordreset-emailtitle": "Tunnuksen tiedot {{GRAMMAR:inessive|{{SITENAME}}}}",
"passwordreset-emailtext-ip": "Joku (todennäköisesti sinä, IP-osoitteesta $1) pyysi salasanasi\nvaihtamista sivustolla {{SITENAME}} ($4). {{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}}\nyhdistettynä tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nKirjaudu sisään nyt ja valitse uusi salasana heti. Jos joku toinen teki tämän pyynnön \ntai jos muistitkin vanhan salasanasi etkä halua enää muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanasi käyttämistä.",
- "passwordreset-emailtext-user": "Käyttäjä $1 pyysi muistutusta tunnuksesi tiedoista sivustolla {{SITENAME}} ($4).\n{{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}} liitetty tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nSinun kannattaa kirjautua sisään ja valita uusi salasana. Jos joku toinen teki tämän\npyynnön, tai muistat sittenkin vanhan salasanasi, etkä halua muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanan käyttöä.",
+ "passwordreset-emailtext-user": "Käyttäjä $1 pyysi muistutusta tunnuksesi tiedoista {{GRAMMAR:inessive|{{SITENAME}}}} ($4).\n{{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}} liitetty tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nSinun kannattaa kirjautua sisään ja valita uusi salasana. Jos joku toinen teki tämän\npyynnön, tai muistat sittenkin vanhan salasanasi, etkä halua muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanan käyttöä.",
"passwordreset-emailelement": "Käyttäjätunnus: \n$1\n\nVäliaikainen salasana: \n$2",
"passwordreset-emailsentemail": "Jos tämä on sinun tunnuksellesi rekisteröity sähköpostiosoite, salasanan uudistamisesta kertova viesti lähetetään.",
"passwordreset-emailsentusername": "Jos on olemassa vastaava rekisteröity sähköpostiosoite, salasanan uudistamisesta kertova viesti lähetetään.",
"continue-editing": "Siirry muokkauskenttään",
"previewconflict": "Tämä esikatselu näyttää miltä muokkausalueella oleva teksti näyttää tallennettuna.",
"session_fail_preview": "Muokkaustasi ei voitu tallentaa, koska istuntosi tiedot ovat kadonneet.\n\nSaatat olla kirjautunut ulos. '''Varmista, että olet edelleen kirjautunut sisään ja yritä uudelleen'''. Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään, ja varmista, että selaimesi sallii evästeet tältä sivustolta.",
- "session_fail_preview_html": "Valitettavasti muokkaustasi ei voitu käsitellä istunnon tietojen katoamisen vuoksi.\n\n<em>Koska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi</em>\n\n<strong>Jos tämä on oikea muokkausyritys, yritä uudelleen.</strong> Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
+ "session_fail_preview_html": "Valitettavasti muokkaustasi ei voitu käsitellä istunnon tietojen katoamisen vuoksi.\n\n<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi</em>\n\n<strong>Jos tämä on oikea muokkausyritys, yritä uudelleen.</strong> Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
"token_suffix_mismatch": "'''Muokkauksesi on hylätty, koska asiakasohjelmasi ei osaa käsitellä välimerkkejä muokkaustarkisteessa. Syynä voi olla viallinen välityspalvelin.'''",
"edit_form_incomplete": "'''Osa muokkauslomakkeesta ei saavuttanut palvelinta. Tarkista, että muokkauksesi ovat vahingoittumattomia ja yritä uudelleen.'''",
"editing": "Muokataan sivua $1",
"upload-form-label-infoform-categories": "Luokat",
"upload-form-label-infoform-date": "Päivämäärä",
"upload-form-label-own-work-message-generic-local": "Vakuutan, että tallennan tämän tiedoston noudattaen {{GRAMMAR:inessive|{{SITENAME}}}} voimassa olevia käyttöehtoja sekä lisenssejä koskevia käytäntöjä.",
- "upload-form-label-not-own-work-message-generic-local": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa sivustolla {{SITENAME}}, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
+ "upload-form-label-not-own-work-message-generic-local": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa {{GRAMMAR:inessive|{{SITENAME}}}}, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
"upload-form-label-not-own-work-local-generic-local": "Voit myös kokeilla [[Special:Upload|yleistä tallentamista]].",
"upload-form-label-own-work-message-generic-foreign": "Ymmärrän, että olen tallentamassa tätä tiedostoa yhteiseen mediasäilöön. Vakuutan, että teen tämän noudattaen asiaankuuluvia käyttöehtoja ja lisenssejä koskevia käytäntöjä.",
"upload-form-label-not-own-work-message-generic-foreign": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa yhteisessä mediasäilössä, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
- "upload-form-label-not-own-work-local-generic-foreign": "Voit myös kokeilla [[Special:Upload|tallennussivua sivustolla {{SITENAME}}]]. Saattaa olla, että tämän tiedoston tallentaminen sinne on mahdollista siellä voimassa olevien käytäntöjen mukaisesti.",
+ "upload-form-label-not-own-work-local-generic-foreign": "Voit myös kokeilla [[Special:Upload|tallennussivua {{GRAMMAR:inessive|{{SITENAME}}}}]]. Saattaa olla, että tämän tiedoston tallentaminen sinne on mahdollista siellä voimassa olevien käytäntöjen mukaisesti.",
"backend-fail-stream": "Tiedoston $1 virtauttaminen epäonnistui.",
"backend-fail-backup": "Tiedostoa $1 ei voitu varmuuskopioida.",
"backend-fail-notexists": "Tiedostoa $1 ei ole olemassa.",
"emailccsubject": "Kopio lähettämästäsi viestistä osoitteeseen $1: $2",
"emailsent": "Sähköposti lähetetty",
"emailsenttext": "Sähköpostiviestisi on lähetetty.",
- "emailuserfooter": "Tämän sähköpostin {{GENDER:$1|lähetti}} $1 vastaanottajalle {{GENDER:$2|$2}} käyttämällä ”{{int:emailuser}}” -toimintoa {{GRAMMAR:inessive|{{SITENAME}}}}. Jos vastaat tähän sähköpostiin, sinun sähköpostiviestisi lähetetään suoraan {{GENDER:$1|alkuperäiselle lähettäjälle}} ja samalla paljastetaan {{GENDER:$2|sinun}} sähköpostiosoitteesi {{GENDER:$1|hänelle}}.",
+ "emailuserfooter": "Tämän sähköpostin {{GENDER:$1|lähetti}} $1 vastaanottajalle {{GENDER:$2|$2}} käyttämällä ”{{int:emailuser}}” -toimintoa {{GRAMMAR:inessive|{{SITENAME}}}}. Jos vastaat tähän sähköpostiin, sähköpostiviestisi lähetetään suoraan {{GENDER:$1|alkuperäiselle lähettäjälle}} ja samalla paljastetaan {{GENDER:$2|sinun}} sähköpostiosoitteesi {{GENDER:$1|hänelle}}.",
"usermessage-summary": "Jätetään järjestelmäviesti.",
"usermessage-editor": "Järjestelmäviestittäjä",
"watchlist": "Tarkkailulista",
"deletereason-dropdown": "* Yleiset poistosyyt\n** Spam tai mainossivu\n** Vandalismi\n** Tekijänoikeusrikkomus\n** Sivun tekijän pyyntö\n** Virheellinen ohjaus",
"delete-edit-reasonlist": "Muokkaa poistosyitä",
"delete-toobig": "Tällä sivulla on pitkä muokkaushistoria, yli $1 {{PLURAL:$1|versio|versiota}}. \nTämänkaltaisten sivujen poistamista on rajoitettu. Tällä ehkäistään {{GRAMMAR:genitive|{{SITENAME}}}} vaurioitumista tahattomasti.",
- "delete-warning-toobig": "Tällä sivulla on pitkä muutoshistoria – yli $1 {{PLURAL:$1|versio|versiota}}. Näin suurien muutoshistorioiden poistaminen voi haitata sivuston suorituskykyä.",
+ "delete-warning-toobig": "Tällä sivulla on pitkä muutoshistoria – yli $1 {{PLURAL:$1|versio|versiota}}. Näin suurien muutoshistorioiden poistaminen voi haitata {{GRAMMAR:genitive|{{SITENAME}}}} suorituskykyä.",
"deleteprotected": "Et voi poistaa tätä sivua, koska se on suojattu.",
"deleting-backlinks-warning": "<strong>Varoitus:</strong> Sivulle, jota olet poistamassa, johtaa [[Special:WhatLinksHere/{{FULLPAGENAME}}|linkkejä muilta sivuilta]], taikka sivu on sisällytetty muuhun sivuun.",
"deleting-subpages-warning": "<strong>Varoitus:</strong> Sivu jota olet poistamassa on [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|alasivu|$1 alasivua|51=yli 50 alasivua}}]].",
"invalidateemail": "Sähköpostiosoitteen varmennuksen peruuttaminen",
"notificationemail_subject_changed": "{{GRAMMAR:genitive|{{SITENAME}}}} rekisteröity sähköpostiosoite on vaihdettu",
"notificationemail_subject_removed": "{{GRAMMAR:genitive|{{SITENAME}}}} rekisteröity sähköpostiosoite on poistettu",
- "notificationemail_body_changed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on vaihtanut tunnuksen ”$2” sähköpostiosoitteeksi ”$3” sivustolla {{SITENAME}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
- "notificationemail_body_removed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on poistanut tunnuksen ”$2” sähköpostiosoitteen sivustolla {{SITENAME}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
+ "notificationemail_body_changed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on vaihtanut tunnuksen ”$2” sähköpostiosoitteeksi ”$3” {{GRAMMAR:inessive|{{SITENAME}}}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
+ "notificationemail_body_removed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on poistanut tunnuksen ”$2” sähköpostiosoitteen {{GRAMMAR:inessive|{{SITENAME}}}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
"scarytranscludedisabled": "[Wikienvälinen sisällytys ei ole käytössä]",
"scarytranscludefailed": "[Mallineen hakeminen epäonnistui: $1]",
"scarytranscludefailed-httpstatus": "[Mallineen hakeminen epäonnistui: $1 HTTP $2]",
"expand_templates_generate_xml": "Näytä XML-jäsennyspuu",
"expand_templates_generate_rawhtml": "Näytä raaka HTML",
"expand_templates_preview": "Esikatselu",
- "expand_templates_preview_fail_html": "<em>Koska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi ja koska istunnon tiedot ovat kadonneet, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos yritit esikatsella sivua, yritä uudestaan.</strong>\nJos esikatselu ei vieläkään toimi, yritä [[Special:UserLogout|kirjautua ulos]] ja sitten kirjautua uudestaan sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
- "expand_templates_preview_fail_html_anon": "<em>Koska sivustolla {{SITENAME}} on käytössä puhdas HTML-koodi ja koska et ole kirjautunut sisään, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.</strong>",
+ "expand_templates_preview_fail_html": "<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä suodattamaton HTML-koodi ja koska istunnon tiedot ovat kadonneet, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos yritit esikatsella sivua, yritä uudestaan.</strong>\nJos esikatselu ei vieläkään toimi, yritä [[Special:UserLogout|kirjautua ulos]] ja sitten kirjautua uudestaan sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
+ "expand_templates_preview_fail_html_anon": "<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä puhdas HTML-koodi ja et ole kirjautunut sisään, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.</strong>",
"expand_templates_input_missing": "Sinun on annettava ainakin jotakin wikitekstiä syötteeksi.",
"pagelanguage": "Sivun kielen vaihto",
"pagelang-name": "Sivu",
"delete-confirm": "Supprimer « $1 »",
"delete-legend": "Supprimer",
"historywarning": "<strong>Attention :</strong> la page que vous êtes sur le point de supprimer a un historique avec $1 {{PLURAL:$1|version|versions}} :",
- "historyaction-submit": "Lister",
+ "historyaction-submit": "Afficher les révisions",
"confirmdeletetext": "Vous êtes sur le point de supprimer une page ou un fichier, ainsi que toutes ses versions antérieures historisées. Veuillez confirmer que c’est bien là ce que vous voulez faire, que vous en comprenez les conséquences et que vous faites ceci en accord avec les [[{{MediaWiki:Policy-url}}|règles internes]].",
"actioncomplete": "Action effectuée",
"actionfailed": "L'action a échoué",
"nstab-special": "Bysûndere side",
"nstab-project": "Projektside",
"nstab-image": "Bestân",
- "nstab-mediawiki": "Berjocht",
+ "nstab-mediawiki": "Systeemberjocht",
"nstab-template": "Berjocht",
"nstab-help": "Helpside",
"nstab-category": "Kategory",
"mainpage-nstab": "Haadside",
"nosuchaction": "Unbekende aksje.",
- "nosuchactiontext": "De opdracht yn de URL is ûnjildich.\nMooglik hasto in typefout makke yn de URL of in ferkearde keppeling folge.\nIt soe likegoed in programmatuerflater fan {{SITENAME}} wêze kinne.",
+ "nosuchactiontext": "De opdracht yn de URL is ûnjildich.\nMooglik hawwe jo in typflater yn 'e URL makke of in ferkearde keppeling folge.\nIt soe likegoed in programmatuerflater fan {{SITENAME}} wêze kinne.",
"nosuchspecialpage": "Gjin soksoarte bysûndere side",
"nospecialpagetext": "<strong>Jo hawwe in ûnjildige bysûndere side opfrege.</strong>\n\nIn list fan jildige bysûndere siden stiet op [[Special:SpecialPages|{{int:specialpages}}]].",
"error": "Flater",
"noname": "Jo hawwe gjin jildige meidochnamme opjûn.",
"loginsuccesstitle": "Oanmelden slagge.",
"loginsuccess": "<strong>Jo binne no oanmeld op {{SITENAME}} as \"$1\".</strong>",
- "nosuchuser": "Der is gjin meidogger \"$1\".\nKontrolearje de stavering, of [[Special:CreateAccount|meitsje in nije meidogger oan]].",
+ "nosuchuser": "Der is gjin meidogger mei de namme \"$1\".\nMeidochnammen binne haadlettergefoelich.\nSjoch de stavering nei, of [[Special:CreateAccount|meitsje in nij akkount oan]].",
"nosuchusershort": "Der is gjin meidogger mei de namme \"$1\". It is goed skreaun?",
"nouserspecified": "Jo moatte in meidochnamme opjaan.",
"wrongpassword": "Ferkearde meidochnamme of wachtwurd ynfolle.\nBesykje it nochris.",
"headline_sample": "Koptekst",
"headline_tip": "Underkopke",
"nowiki_sample": "Foechje hjir platte tekst yn",
- "nowiki_tip": "Negearje it wiki formaat",
+ "nowiki_tip": "Wiki-opmaak negearje",
"image_sample": "Foarbyld.jpg",
"image_tip": "Mediabestân",
"media_tip": "Link nei bestân",
"tooltip-ca-nstab-special": "Dit is in bysûndere side, en kin net bewurke wurde",
"tooltip-ca-nstab-project": "Projektside sjen litte",
"tooltip-ca-nstab-image": "De bestânsside sjen litte",
- "tooltip-ca-nstab-mediawiki": "Systeemberjocht sjen litte",
- "tooltip-ca-nstab-template": "Sjabloan sjen litte",
+ "tooltip-ca-nstab-mediawiki": "It systeemberjocht sjen litte",
+ "tooltip-ca-nstab-template": "It berjocht sjen litte",
"tooltip-ca-nstab-help": "Helpside sjen litte",
"tooltip-ca-nstab-category": "Kategory-side sjen litte",
"tooltip-minoredit": "Markearje dizze feroaring as fan lytse betsjutting",
"mainpage": "Գլխավոր էջ",
"mainpage-description": "Գլխավոր էջ",
"policy-url": "Project:Կանոնակարգ",
- "portal": "Խորհրդարան",
+ "portal": "Համայնքային պորտալ",
"portal-url": "Project:Համայնքային պորտալ",
"privacy": "Գաղտնիության քաղաքականություն",
"privacypage": "Project:Գաղտնիության քաղաքականություն",
"logentry-newusers-autocreate": "$1 մասնակցային հաշիւը {{GENDER:$2|ստեղծուած է}} ինքնաբերաբար",
"logentry-upload-upload": "$1 {{GENDER:$2|ներբեռնուած է}} $3",
"logentry-upload-overwrite": "$1 {{GENDER:$2|վերբեռնեց}} $3ի նոր տարբերակ",
+ "feedback-cancel": "Չեղարկել",
"searchsuggest-search": "Որոնել {{SITENAME}} կայքին մէջ",
"duration-days": "$1 {{PLURAL:$1|օր}}",
"randomrootpage": "Պատահական արմատ էջ"
"delete-confirm": "Cancella \"$1\"",
"delete-legend": "Cancella",
"historywarning": "'''Attenzione:''' La pagina che stai per cancellare ha una cronologia con $1 {{PLURAL:$1|versione|versioni}}:",
- "historyaction-submit": "Mostra",
+ "historyaction-submit": "Mostra versioni",
"confirmdeletetext": "Stai per cancellare una pagina con tutta la sua cronologia. Per cortesia, conferma che è tua intenzione procedere a tale cancellazione, che hai piena consapevolezza delle conseguenze della tua azione e che essa è conforme alle linee guida stabilite in [[{{MediaWiki:Policy-url}}]].",
"actioncomplete": "Azione completata",
"actionfailed": "Azione fallita",
"delete-confirm": "Läsche vu(n) \"$1\"",
"delete-legend": "Läschen",
"historywarning": "<strong>Opgepasst:</strong> D'Säit déi Dir läsche wëllt huet en Historique mat $1 {{PLURAL:$1|Versioun|Versiounen}}:",
- "historyaction-submit": "Weisen",
+ "historyaction-submit": "Versioune weisen",
"confirmdeletetext": "Dir sidd am Gaang, eng Säit mat hirem kompletten Historique vollstänneg aus der Datebank ze läschen.\nW.e.g. confirméiert, datt Dir dëst wierklech wëllt, datt Dir d'Konsequenze verstitt, an datt dat Ganzt am Aklang mat de [[{{MediaWiki:Policy-url}}|Richtlinne]] geschitt.",
"actioncomplete": "Aktioun ofgeschloss",
"actionfailed": "Aktioun huet net funktionéiert",
"deleting-backlinks-warning": "<strong>Opgepasst:</strong> [[Special:WhatLinksHere/{{FULLPAGENAME}}|Aner Säite]] linken op déi Säit déi Dir am Gaang sidd ze läschen oder déi Säit Déi Dir am Gaang sidd ze läschen ass an aner Säiten agebonn.",
"deleting-subpages-warning": "<strong>Opgepasst:</strong> D'Säit, déi Dir läsche wëllt, huet [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|eng Ënnersäit|$1 Ënnersäiten|51=méi wéi 50 Ënnersäiten}}]].",
"rollback": "Ännerungen zrécksetzen",
+ "rollback-confirmation-confirm": "W.e.g. Konfirméieren",
"rollback-confirmation-yes": "Zrécksetzen",
"rollback-confirmation-no": "Ofbriechen",
"rollbacklink": "Zrécksetzen",
"thu": "پٱن شٱمٱ",
"fri": "جۏمٱ",
"sat": "شٱمٱ",
- "january": "جانڤیٱ",
+ "january": "ژانڤیٱ",
"february": "فڤریٱ",
"march": "مارس",
"april": "آڤریل",
"may_long": "ماٛی",
- "june": "جوئٱن",
+ "june": "ژوئٱن",
"july": "جۊلای",
"august": "آگوست",
"september": "سپتامر",
"october-gen": "اوکتوبر",
"november-gen": "نوڤامر",
"december-gen": "دسامر",
- "jan": "جانڤیٱ",
+ "jan": "ژانڤیٱ",
"feb": "فڤریٱ",
"mar": "مارس",
"apr": "آڤریل",
"may": "ماٛی",
- "jun": "جوئٱن",
+ "jun": "ژوئٱن",
"jul": "جۊلای",
"aug": "آگوست",
"sep": "سپتامر",
"history": "ڤیرگار بٱلگٱ",
"history_short": "ڤیرگار",
"updatedmarker": "د آخئری دییئن مئنە ڤئ هنگوم کو",
- "printableversion": "نۏسخٱ پلا بینی",
+ "printableversion": "نۏسخٱ پلا بیئنی",
"permalink": "هوم پاٛڤٱن هٱمیشاٛیی",
"print": "چاپ گئرئتئن",
"view": "دیئن",
"pool-errorunknown": "خأطا نادیار",
"pool-servererror": "پوٙل ئشمار خئذمأتگە د دأسرئس نی($1).",
"poolcounter-usage-error": "خأطا ڤئ کار گئرئتئن:$1",
- "aboutsite": "دبارٱ {{SITENAME}}",
- "aboutpage": "Project:دبارٱ",
+ "aboutsite": "دٱربارٱ {{SITENAME}}",
+ "aboutpage": "Project:دٱربارٱ",
"copyright": "مینۊنٱیا هان د دٱسرس $1 مٱر یٱ کاٛ ڤ یاٛ گاٛل شیڤاٛ هٱنی نیسٱنٱ بۊٱ.",
"copyrightpage": "{{ns:project}}:کوپی رایت",
"currentevents": "روخ ڤنؽا ایسنی",
"tooltip-ca-watch": "اْزاف کردن اؽ بٱلگٱ ڤ نوم نڤشت پاٛگیریاتو",
"tooltip-ca-unwatch": "ڤرداشتن اؽ بٱلگٱ ڤ نوم نڤشت پاٛگیریاتو",
"tooltip-search": "پاٛ جۊری {{SITENAME}}",
- "tooltip-search-go": "رۉ د بٱلگاٛیؽ کاْ یٱ نوم روسی ها مؽنش ٱلڤٱت ٱر دش بۊئٱ",
+ "tooltip-search-go": "رۉ د بٱلگاٛیؽ کاْ یٱ نوم راسی ها مؽنش ٱلڤٱت ٱر دش بۊئٱ",
"tooltip-search-fulltext": "بٱلگٱیاناْ سی چنی نیسساٛیؽ پاٛ جۊری بٱکو.",
"tooltip-p-logo": "ساٛلٛ سرآسونٱ بٱکؽت",
"tooltip-n-mainpage": "سرآسونٱ ناْ ساٛلٛ بٱکؽت",
"tooltip-n-mainpage-description": "سرآسونٱ ناْ ساٛلٛ بٱکؽت",
"tooltip-n-portal": "دبارٱ پرۉژٱ؛ شما مؽ تونؽت چؽ بٱکؽت؛ د کوجا اؽ چیاناْ بٱجۊرؽت.",
- "tooltip-n-currentevents": "ساڤند دونسمنیایؽ کا هان د روخ ڤنؽا تازٱ باڤ دؽاری بٱک",
+ "tooltip-n-currentevents": "ساڤن دونسمنیایؽ کا هان د روخ ڤٱنؽا تازٱ بۊ دؽاری بٱک",
"tooltip-n-recentchanges": "یاٛ نومگٱ سی آلشتکاریا د ڤیکی",
"tooltip-n-randompage": "سڤار کرد بٱلگٱ بٱختٱکی",
"tooltip-n-help": "یاٛ جاگٱ سی فٱمسن",
"mycontris": "Indėlis",
"anoncontribs": "Indėlis",
"contribsub2": "Naudotojas: {{GENDER:$3|$1}} ($2)",
+ "contributions-subtitle": "{{GENDER:$3|$1}}",
"contributions-userdoesnotexist": "Naudotojo paskyra „$1“ neužregistruota.",
"nocontribs": "Jokie keitimai neatitiko šių kriterijų.",
"uctop": "dabartinis",
"accountcreatedtext": "Корисничката сметка за [[{{ns:User}}:$1|$1]] ([[{{ns:User talk}}:$1|разговор]]) е направена.",
"createaccount-title": "Создавање на сметка за {{SITENAME}}",
"createaccount-text": "Некој направил сметка со вашата е-поштенска адреса на {{SITENAME}} ($4) со име „$2“ и лозинка „$3“.\nБи требало сега да се пријавите и да ја промените вашата лозинка.\n\nМожете да ја занемарите оваа порака ако сметката била направена по грешка.",
- "login-throttled": "Ð\98маÑ\82е пÑ\80емногÑ\83 обиди за наÑ\98ава за кÑ\80аÑ\82ко вÑ\80еме.\nПочекајте $1 пред да се обидете повторно.",
+ "login-throttled": "Ð\9dапÑ\80авивÑ\82е пÑ\80емногÑ\83 обиди за наÑ\98ава.\nПочекајте $1 пред да се обидете повторно.",
"login-abort-generic": "Најавата е неуспешна — Откажано",
"login-migrated-generic": "Вашата сметка е пренесена и корисничкото име веќе не постои на ова вики.",
"loginlanguagelabel": "Јазик: $1",
"rcfilters-restore-default-filters": "Поврати основни филтри",
"rcfilters-clear-all-filters": "Тргни ги сите филтри",
"rcfilters-show-new-changes": "Погл. најнови промени",
- "rcfilters-search-placeholder": "Филтрирање на промени (со менито или пребарајте назив на филтер)",
+ "rcfilters-search-placeholder": "Филтрирање на промени (користете го менито или пребарајте назив на филтер)",
"rcfilters-invalid-filter": "Неважечки филтер",
"rcfilters-empty-filter": "Нема активни филтри. Прикажани се сите придонеси.",
"rcfilters-filterlist-title": "Филтри",
"delete-confirm": "Бришење на „$1“",
"delete-legend": "Бришење",
"historywarning": "<strong>Предупредување:</strong> Страницата што сакате да ја избришете има историја со {{PLURAL:$1|една преработка|$1 преработки}}:",
- "historyaction-submit": "Прикажи",
+ "historyaction-submit": "Прикажи преработки",
"confirmdeletetext": "На пат сте трајно да избришете страница заедно со нејзината историја.\nПотврдете дека имате намера да го направите ова, дека ги разбирате последиците од тоа и дека го правите во согласност со [[{{MediaWiki:Policy-url}}|правилата]].",
"actioncomplete": "Дејството е извршено",
"actionfailed": "Неуспешно дејство",
"duration-hours": "{{PLURAL:$1|еден час|$1 часа}}",
"duration-days": "{{PLURAL:$1|еден ден|$1 дена}}",
"duration-weeks": "$1 {{PLURAL:$1|недела|недели}}",
- "duration-years": "{{PLURAL:$1|$1 година|$1 години}}",
+ "duration-years": "$1 {{PLURAL:$1|година|години}}",
"duration-decades": "$1 {{PLURAL:$1|деценија|децении}}",
"duration-centuries": "$1 {{PLURAL:$1|век|века}}",
"duration-millennia": "$1 {{PLURAL:$1|милениум|милениуми}}",
"userpage-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.\nControleer of u deze pagina wel wilt aanmaken/bewerken.",
"userpage-userdoesnotexist-view": "Gebruikersaccount \"$1\" bestaat niet.",
"blocked-notice-logextract": "Deze gebruiker is momenteel geblokkeerd.\nDe laatste regel uit het blokkeerlogboek wordt hieronder ter referentie weergegeven:",
- "clearyourcache": "<strong>Opmerking:</strong> nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* <strong>Firefox / Safari:</strong> houd <em>Shift</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em> of <em>Ctrl-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Google Chrome:</strong> druk op <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Internet Explorer:</strong> houd <em>Ctrl</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em>\n* '''Opera:''' ga naar <em>Menu → Instellingen</em> (<em>Opera → Voorkeuren</em> op een Mac) en daarna naar <em>Privacy & beveiliging → Browsegegevens wissen... → Tijdelijk opgeslgen afbeeldingen en bestanden</em>.",
+ "clearyourcache": "<strong>Opmerking:</strong> nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* <strong>Firefox / Safari:</strong> houd <em>Shift</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em> of <em>Ctrl-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Google Chrome:</strong> druk op <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Internet Explorer:</strong> houd <em>Ctrl</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em>\n* '''Opera:''' ga naar <em>Menu → Instellingen</em> (<em>Opera → Voorkeuren</em> op een Mac) en daarna naar <em>Privacy & beveiliging → Browsegegevens wissen... → Tijdelijk opgeslagen afbeeldingen en bestanden</em>.",
"usercssyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe CSS te testen alvorens op te slaan.",
"userjsonyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe JSON te testen alvorens op te slaan.",
"userjsyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe JavaScript te testen alvorens op te slaan.",
"delete-confirm": "\"$1\" verwijderen",
"delete-legend": "Verwijderen",
"historywarning": "<strong>Waarschuwing:</strong> de pagina die u wilt verwijderen heeft ongeveer $1 {{PLURAL:$1|versie|versies}}:",
- "historyaction-submit": "Weergeven",
+ "historyaction-submit": "Versies weergeven",
"confirmdeletetext": "U staat op het punt een pagina te verwijderen, inclusief de geschiedenis.\nBevestig hieronder dat dit inderdaad uw bedoeling is, dat u de gevolgen begrijpt en dat de verwijdering overeenstemt met het [[{{MediaWiki:Policy-url}}|beleid]].",
"actioncomplete": "Handeling voltooid",
"actionfailed": "Handeling mislukt",
"page_first": "This is part of the navigation message on the top and bottom of Special pages which are lists of things in alphabetical order, e.g. the '[[Special:Categories|Categories]]' special page. It is followed by the message {{msg-mw|Viewprevnext}}.\n{{Identical|First}}",
"page_last": "This is part of the navigation message on the top and bottom of Special pages which are lists of things in alphabetical order, e.g. the '[[Special:Categories|Categories]]' special page. It is followed by the message {{msg-mw|Viewprevnext}}.\n\n{{Identical|Last}}",
"histlegend": "Text in history page.\n\nSee also:\n* {{msg-mw|Cur}}\n* {{msg-mw|Last}}\n* {{msg-mw|Minoreditletter}}",
- "history-fieldset-title": "Fieldset label in the edit history pages.",
+ "history-fieldset-title": "Form legend label in the edit history page.",
"history-show-deleted": "CheckBox to show only per [[mw:Manual:RevisionDelete|RevisionDelete]] deleted versions.\n\nUsed in History and [[Special:Contributions]].",
"history_copyright": "{{notranslate}}",
"histfirst": "This is part of the navigation message on the top and bottom of Page History pages which are lists of things in date order, e.g. [{{canonicalurl:Support|action=history}} Page History of Support].\n\nIt is followed by the message {{msg-mw|Viewprevnext}}.\n{{Identical|Oldest}}",
require_once __DIR__ . '/../Maintenance.php';
+use Wikimedia\StaticArrayWriter;
+
/**
* Generate first letter data files for Collation.php
*
require_once __DIR__ . '/../Maintenance.php';
+use Wikimedia\StaticArrayWriter;
+
/**
* Generates the normalizer data file for Arabic.
*
require_once __DIR__ . '/../Maintenance.php';
+use Wikimedia\StaticArrayWriter;
+
/**
* Generates the normalizer data file for Malayalam.
*
require_once __DIR__ . '/Maintenance.php';
+use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\IDatabase;
/**
}
# cl_type will be wrong for lots of pages if cl_collation is 0,
# so let's update it while we're here.
- $type = MWNamespace::getCategoryLinkType( $title->getNamespace() );
+ $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+ getCategoryLinkType( $title->getNamespace() );
$newSortKey = $collation->getSortKey(
$title->getCategorySortkey( $prefix ) );
if ( $verboseStats ) {
],
],
'mediawiki.action.history' => [
+ 'dependencies' => [ 'jquery.makeCollapsible' ],
'scripts' => 'resources/src/mediawiki.action/mediawiki.action.history.js',
'styles' => 'resources/src/mediawiki.action/mediawiki.action.history.css',
],
],
],
'mediawiki.special.block' => [
- 'scripts' => 'resources/src/mediawiki.special.block.js',
+ 'localBasePath' => "$IP/resources/src",
+ 'remoteBasePath' => "$wgResourceBasePath/resources/src",
+ 'packageFiles' => [
+ 'mediawiki.special.block.js',
+ [ 'name' => 'config.json', 'config' => [
+ 'EnablePartialBlocks',
+ 'BlockAllowsUTEdit',
+ ] ],
+ ],
'dependencies' => [
'oojs-ui-core',
'oojs-ui.styles.icons-editing-core',
type: multi-file
files:
mustache.js:
- src: https://raw.githubusercontent.com/janl/mustache.js/v1.0.0/mustache.js
- integrity: sha384-k2UYqmzoiq/qgIzZvcYBxbXQW4YdPAsXDOTkHTGb9TCZ9sjCkyT4TlaUN0wQRkql
+ src: https://raw.githubusercontent.com/janl/mustache.js/v3.0.1/mustache.js
+ integrity: sha384-YjAj6Nll7fkEWzxTlE9o3NWC9qdZt1Upat6Afjib9eLs8lTODpSKEBHeXq8o/VUH
LICENSE:
- src: https://raw.githubusercontent.com/janl/mustache.js/v1.0.0/LICENSE
- integrity: sha384-MYVwXwula9+YkyXexOJVZ0v0DaVvG22uX57mNq5Di+7u8OH9EG9q3yuXkp1Iehiq
+ src: https://raw.githubusercontent.com/janl/mustache.js/v3.0.1/LICENSE
+ integrity: sha384-j2EDj6YtCRgFvYDtzo6pXzbskIj/K1Yg65BL0j3/L6UIHxbMtRMJwC/W+XoYx0FZ
oojs:
type: tar
Copyright (c) 2009 Chris Wanstrath (Ruby)
Copyright (c) 2010-2014 Jan Lehnardt (JavaScript)
+Copyright (c) 2010-2015 The mustache.js community
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
* http://github.com/janl/mustache.js
*/
-/*global define: false*/
+/*global define: false Mustache: true*/
-(function (global, factory) {
- if (typeof exports === "object" && exports) {
+(function defineMustache (global, factory) {
+ if (typeof exports === 'object' && exports && typeof exports.nodeName !== 'string') {
factory(exports); // CommonJS
- } else if (typeof define === "function" && define.amd) {
+ } else if (typeof define === 'function' && define.amd) {
define(['exports'], factory); // AMD
} else {
- factory(global.Mustache = {}); // <script>
+ global.Mustache = {};
+ factory(global.Mustache); // script, wsh, asp
}
-}(this, function (mustache) {
+}(this, function mustacheFactory (mustache) {
- var Object_toString = Object.prototype.toString;
- var isArray = Array.isArray || function (object) {
- return Object_toString.call(object) === '[object Array]';
+ var objectToString = Object.prototype.toString;
+ var isArray = Array.isArray || function isArrayPolyfill (object) {
+ return objectToString.call(object) === '[object Array]';
};
- function isFunction(object) {
+ function isFunction (object) {
return typeof object === 'function';
}
- function escapeRegExp(string) {
- return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&");
+ /**
+ * More correct typeof string handling array
+ * which normally returns typeof 'object'
+ */
+ function typeStr (obj) {
+ return isArray(obj) ? 'array' : typeof obj;
+ }
+
+ function escapeRegExp (string) {
+ return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&');
+ }
+
+ /**
+ * Null safe way of checking whether or not an object,
+ * including its prototype, has a given property
+ */
+ function hasProperty (obj, propName) {
+ return obj != null && typeof obj === 'object' && (propName in obj);
+ }
+
+ /**
+ * Safe way of detecting whether or not the given thing is a primitive and
+ * whether it has the given property
+ */
+ function primitiveHasOwnProperty (primitive, propName) {
+ return (
+ primitive != null
+ && typeof primitive !== 'object'
+ && primitive.hasOwnProperty
+ && primitive.hasOwnProperty(propName)
+ );
}
// Workaround for https://issues.apache.org/jira/browse/COUCHDB-577
// See https://github.com/janl/mustache.js/issues/189
- var RegExp_test = RegExp.prototype.test;
- function testRegExp(re, string) {
- return RegExp_test.call(re, string);
+ var regExpTest = RegExp.prototype.test;
+ function testRegExp (re, string) {
+ return regExpTest.call(re, string);
}
var nonSpaceRe = /\S/;
- function isWhitespace(string) {
+ function isWhitespace (string) {
return !testRegExp(nonSpaceRe, string);
}
var entityMap = {
- "&": "&",
- "<": "<",
- ">": ">",
+ '&': '&',
+ '<': '<',
+ '>': '>',
'"': '"',
"'": ''',
- "/": '/'
+ '/': '/',
+ '`': '`',
+ '=': '='
};
- function escapeHtml(string) {
- return String(string).replace(/[&<>"'\/]/g, function (s) {
+ function escapeHtml (string) {
+ return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) {
return entityMap[s];
});
}
* array of tokens in the subtree and 2) the index in the original template at
* which the closing tag for that section begins.
*/
- function parseTemplate(template, tags) {
+ function parseTemplate (template, tags) {
if (!template)
return [];
// Strips all whitespace tokens array for the current line
// if there was a {{#tag}} on it and otherwise only space.
- function stripSpace() {
+ function stripSpace () {
if (hasTag && !nonSpace) {
while (spaces.length)
delete tokens[spaces.pop()];
}
var openingTagRe, closingTagRe, closingCurlyRe;
- function compileTags(tags) {
- if (typeof tags === 'string')
- tags = tags.split(spaceRe, 2);
+ function compileTags (tagsToCompile) {
+ if (typeof tagsToCompile === 'string')
+ tagsToCompile = tagsToCompile.split(spaceRe, 2);
- if (!isArray(tags) || tags.length !== 2)
- throw new Error('Invalid tags: ' + tags);
+ if (!isArray(tagsToCompile) || tagsToCompile.length !== 2)
+ throw new Error('Invalid tags: ' + tagsToCompile);
- openingTagRe = new RegExp(escapeRegExp(tags[0]) + '\\s*');
- closingTagRe = new RegExp('\\s*' + escapeRegExp(tags[1]));
- closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tags[1]));
+ openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*');
+ closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1]));
+ closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1]));
}
compileTags(tags || mustache.tags);
* Combines the values of consecutive text tokens in the given `tokens` array
* to a single token.
*/
- function squashTokens(tokens) {
+ function squashTokens (tokens) {
var squashedTokens = [];
var token, lastToken;
* all tokens that appear in that section and 2) the index in the original
* template that represents the end of that section.
*/
- function nestTokens(tokens) {
+ function nestTokens (tokens) {
var nestedTokens = [];
var collector = nestedTokens;
var sections = [];
token = tokens[i];
switch (token[0]) {
- case '#':
- case '^':
- collector.push(token);
- sections.push(token);
- collector = token[4] = [];
- break;
- case '/':
- section = sections.pop();
- section[5] = token[2];
- collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
- break;
- default:
- collector.push(token);
+ case '#':
+ case '^':
+ collector.push(token);
+ sections.push(token);
+ collector = token[4] = [];
+ break;
+ case '/':
+ section = sections.pop();
+ section[5] = token[2];
+ collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
+ break;
+ default:
+ collector.push(token);
}
}
* A simple string scanner that is used by the template parser to find
* tokens in template strings.
*/
- function Scanner(string) {
+ function Scanner (string) {
this.string = string;
this.tail = string;
this.pos = 0;
/**
* Returns `true` if the tail is empty (end of string).
*/
- Scanner.prototype.eos = function () {
- return this.tail === "";
+ Scanner.prototype.eos = function eos () {
+ return this.tail === '';
};
/**
* Tries to match the given regular expression at the current position.
* Returns the matched text if it can match, the empty string otherwise.
*/
- Scanner.prototype.scan = function (re) {
+ Scanner.prototype.scan = function scan (re) {
var match = this.tail.match(re);
if (!match || match.index !== 0)
* Skips all text until the given regular expression can be matched. Returns
* the skipped string, which is the entire tail if no match can be made.
*/
- Scanner.prototype.scanUntil = function (re) {
+ Scanner.prototype.scanUntil = function scanUntil (re) {
var index = this.tail.search(re), match;
switch (index) {
- case -1:
- match = this.tail;
- this.tail = "";
- break;
- case 0:
- match = "";
- break;
- default:
- match = this.tail.substring(0, index);
- this.tail = this.tail.substring(index);
+ case -1:
+ match = this.tail;
+ this.tail = '';
+ break;
+ case 0:
+ match = '';
+ break;
+ default:
+ match = this.tail.substring(0, index);
+ this.tail = this.tail.substring(index);
}
this.pos += match.length;
* Represents a rendering context by wrapping a view object and
* maintaining a reference to the parent context.
*/
- function Context(view, parentContext) {
- this.view = view == null ? {} : view;
+ function Context (view, parentContext) {
+ this.view = view;
this.cache = { '.': this.view };
this.parent = parentContext;
}
* Creates a new context using the given view with this context
* as the parent.
*/
- Context.prototype.push = function (view) {
+ Context.prototype.push = function push (view) {
return new Context(view, this);
};
* Returns the value of the given name in this context, traversing
* up the context hierarchy if the value is absent in this context's view.
*/
- Context.prototype.lookup = function (name) {
+ Context.prototype.lookup = function lookup (name) {
var cache = this.cache;
var value;
- if (name in cache) {
+ if (cache.hasOwnProperty(name)) {
value = cache[name];
} else {
- var context = this, names, index;
+ var context = this, intermediateValue, names, index, lookupHit = false;
while (context) {
if (name.indexOf('.') > 0) {
- value = context.view;
+ intermediateValue = context.view;
names = name.split('.');
index = 0;
- while (value != null && index < names.length)
- value = value[names[index++]];
- } else if (typeof context.view == 'object') {
- value = context.view[name];
+ /**
+ * Using the dot notion path in `name`, we descend through the
+ * nested objects.
+ *
+ * To be certain that the lookup has been successful, we have to
+ * check if the last object in the path actually has the property
+ * we are looking for. We store the result in `lookupHit`.
+ *
+ * This is specially necessary for when the value has been set to
+ * `undefined` and we want to avoid looking up parent contexts.
+ *
+ * In the case where dot notation is used, we consider the lookup
+ * to be successful even if the last "object" in the path is
+ * not actually an object but a primitive (e.g., a string, or an
+ * integer), because it is sometimes useful to access a property
+ * of an autoboxed primitive, such as the length of a string.
+ **/
+ while (intermediateValue != null && index < names.length) {
+ if (index === names.length - 1)
+ lookupHit = (
+ hasProperty(intermediateValue, names[index])
+ || primitiveHasOwnProperty(intermediateValue, names[index])
+ );
+
+ intermediateValue = intermediateValue[names[index++]];
+ }
+ } else {
+ intermediateValue = context.view[name];
+
+ /**
+ * Only checking against `hasProperty`, which always returns `false` if
+ * `context.view` is not an object. Deliberately omitting the check
+ * against `primitiveHasOwnProperty` if dot notation is not used.
+ *
+ * Consider this example:
+ * ```
+ * Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"})
+ * ```
+ *
+ * If we were to check also against `primitiveHasOwnProperty`, as we do
+ * in the dot notation case, then render call would return:
+ *
+ * "The length of a football field is 9."
+ *
+ * rather than the expected:
+ *
+ * "The length of a football field is 100 yards."
+ **/
+ lookupHit = hasProperty(context.view, name);
}
- if (value != null)
+ if (lookupHit) {
+ value = intermediateValue;
break;
+ }
context = context.parent;
}
* string, given a context. It also maintains a cache of templates to
* avoid the need to parse the same template twice.
*/
- function Writer() {
+ function Writer () {
this.cache = {};
}
/**
* Clears all cached templates in this writer.
*/
- Writer.prototype.clearCache = function () {
+ Writer.prototype.clearCache = function clearCache () {
this.cache = {};
};
/**
- * Parses and caches the given `template` and returns the array of tokens
+ * Parses and caches the given `template` according to the given `tags` or
+ * `mustache.tags` if `tags` is omitted, and returns the array of tokens
* that is generated from the parse.
*/
- Writer.prototype.parse = function (template, tags) {
+ Writer.prototype.parse = function parse (template, tags) {
var cache = this.cache;
- var tokens = cache[template];
+ var cacheKey = template + ':' + (tags || mustache.tags).join(':');
+ var tokens = cache[cacheKey];
if (tokens == null)
- tokens = cache[template] = parseTemplate(template, tags);
+ tokens = cache[cacheKey] = parseTemplate(template, tags);
return tokens;
};
* names and templates of partials that are used in the template. It may
* also be a function that is used to load partial templates on the fly
* that takes a single argument: the name of the partial.
+ *
+ * If the optional `tags` argument is given here it must be an array with two
+ * string values: the opening and closing tags used in the template (e.g.
+ * [ "<%", "%>" ]). The default is to mustache.tags.
*/
- Writer.prototype.render = function (template, view, partials) {
- var tokens = this.parse(template);
+ Writer.prototype.render = function render (template, view, partials, tags) {
+ var tokens = this.parse(template, tags);
var context = (view instanceof Context) ? view : new Context(view);
- return this.renderTokens(tokens, context, partials, template);
+ return this.renderTokens(tokens, context, partials, template, tags);
};
/**
* If the template doesn't use higher-order sections, this argument may
* be omitted.
*/
- Writer.prototype.renderTokens = function (tokens, context, partials, originalTemplate) {
+ Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, tags) {
var buffer = '';
- // This function is used to render an arbitrary template
- // in the current context by higher-order sections.
- var self = this;
- function subRender(template) {
- return self.render(template, context, partials);
- }
-
- var token, value;
+ var token, symbol, value;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
+ value = undefined;
token = tokens[i];
+ symbol = token[0];
- switch (token[0]) {
- case '#':
- value = context.lookup(token[1]);
+ if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate);
+ else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate);
+ else if (symbol === '>') value = this.renderPartial(token, context, partials, tags);
+ else if (symbol === '&') value = this.unescapedValue(token, context);
+ else if (symbol === 'name') value = this.escapedValue(token, context);
+ else if (symbol === 'text') value = this.rawValue(token);
- if (!value)
- continue;
+ if (value !== undefined)
+ buffer += value;
+ }
- if (isArray(value)) {
- for (var j = 0, valueLength = value.length; j < valueLength; ++j) {
- buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate);
- }
- } else if (typeof value === 'object' || typeof value === 'string') {
- buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate);
- } else if (isFunction(value)) {
- if (typeof originalTemplate !== 'string')
- throw new Error('Cannot use higher-order sections without the original template');
+ return buffer;
+ };
- // Extract the portion of the original template that the section contains.
- value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);
+ Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate) {
+ var self = this;
+ var buffer = '';
+ var value = context.lookup(token[1]);
- if (value != null)
- buffer += value;
- } else {
- buffer += this.renderTokens(token[4], context, partials, originalTemplate);
- }
+ // This function is used to render an arbitrary template
+ // in the current context by higher-order sections.
+ function subRender (template) {
+ return self.render(template, context, partials);
+ }
- break;
- case '^':
- value = context.lookup(token[1]);
+ if (!value) return;
- // Use JavaScript's definition of falsy. Include empty arrays.
- // See https://github.com/janl/mustache.js/issues/186
- if (!value || (isArray(value) && value.length === 0))
- buffer += this.renderTokens(token[4], context, partials, originalTemplate);
+ if (isArray(value)) {
+ for (var j = 0, valueLength = value.length; j < valueLength; ++j) {
+ buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate);
+ }
+ } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') {
+ buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate);
+ } else if (isFunction(value)) {
+ if (typeof originalTemplate !== 'string')
+ throw new Error('Cannot use higher-order sections without the original template');
- break;
- case '>':
- if (!partials)
- continue;
+ // Extract the portion of the original template that the section contains.
+ value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);
- value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
+ if (value != null)
+ buffer += value;
+ } else {
+ buffer += this.renderTokens(token[4], context, partials, originalTemplate);
+ }
+ return buffer;
+ };
- if (value != null)
- buffer += this.renderTokens(this.parse(value), context, partials, value);
+ Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate) {
+ var value = context.lookup(token[1]);
- break;
- case '&':
- value = context.lookup(token[1]);
+ // Use JavaScript's definition of falsy. Include empty arrays.
+ // See https://github.com/janl/mustache.js/issues/186
+ if (!value || (isArray(value) && value.length === 0))
+ return this.renderTokens(token[4], context, partials, originalTemplate);
+ };
- if (value != null)
- buffer += value;
+ Writer.prototype.renderPartial = function renderPartial (token, context, partials, tags) {
+ if (!partials) return;
- break;
- case 'name':
- value = context.lookup(token[1]);
+ var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
+ if (value != null)
+ return this.renderTokens(this.parse(value, tags), context, partials, value);
+ };
- if (value != null)
- buffer += mustache.escape(value);
+ Writer.prototype.unescapedValue = function unescapedValue (token, context) {
+ var value = context.lookup(token[1]);
+ if (value != null)
+ return value;
+ };
- break;
- case 'text':
- buffer += token[1];
- break;
- }
- }
+ Writer.prototype.escapedValue = function escapedValue (token, context) {
+ var value = context.lookup(token[1]);
+ if (value != null)
+ return mustache.escape(value);
+ };
- return buffer;
+ Writer.prototype.rawValue = function rawValue (token) {
+ return token[1];
};
- mustache.name = "mustache.js";
- mustache.version = "1.0.0";
- mustache.tags = [ "{{", "}}" ];
+ mustache.name = 'mustache.js';
+ mustache.version = '3.0.1';
+ mustache.tags = [ '{{', '}}' ];
// All high-level mustache.* functions use this writer.
var defaultWriter = new Writer();
/**
* Clears all cached templates in the default writer.
*/
- mustache.clearCache = function () {
+ mustache.clearCache = function clearCache () {
return defaultWriter.clearCache();
};
* array of tokens it contains. Doing this ahead of time avoids the need to
* parse templates on the fly as they are rendered.
*/
- mustache.parse = function (template, tags) {
+ mustache.parse = function parse (template, tags) {
return defaultWriter.parse(template, tags);
};
/**
* Renders the `template` with the given `view` and `partials` using the
- * default writer.
+ * default writer. If the optional `tags` argument is given here it must be an
+ * array with two string values: the opening and closing tags used in the
+ * template (e.g. [ "<%", "%>" ]). The default is to mustache.tags.
*/
- mustache.render = function (template, view, partials) {
- return defaultWriter.render(template, view, partials);
+ mustache.render = function render (template, view, partials, tags) {
+ if (typeof template !== 'string') {
+ throw new TypeError('Invalid template! Template should be a "string" ' +
+ 'but "' + typeStr(template) + '" was given as the first ' +
+ 'argument for mustache#render(template, view, partials)');
+ }
+
+ return defaultWriter.render(template, view, partials, tags);
};
- // This is here for backwards compatibility with 0.4.x.
- mustache.to_html = function (template, view, partials, send) {
+ // This is here for backwards compatibility with 0.4.x.,
+ /*eslint-disable */ // eslint wants camel cased function name
+ mustache.to_html = function to_html (template, view, partials, send) {
+ /*eslint-enable*/
+
var result = mustache.render(template, view, partials);
if (isFunction(send)) {
mustache.Context = Context;
mustache.Writer = Writer;
+ return mustache;
}));
}
}
+fieldset.mw-collapsible .mw-collapsible-toggle {
+ position: absolute;
+ right: 0;
+ z-index: 1;
+}
+
// special treatment for list items to match above
// !important necessary to override overly-specific float left and right above.
ol.mw-collapsible:not( @{exclude} ):before,
/* Basic styles for the history page */
-@import 'mediawiki.mixins';
-
-/* Visually hide repeating text, but leave in for better form navigation on screen readers. */
-.action-history .mw-htmlform-ooui .oo-ui-fieldsetLayout:first-child .oo-ui-fieldsetLayout-header {
- .mixin-screen-reader-text();
-}
#pagehistory .history-user {
margin-left: 0.4em;
--- /dev/null
+{
+ "parserOptions": {
+ "sourceType": "module"
+ }
+}
-( function () {
- 'use strict';
+'use strict';
- var config = require( './config.json' ),
- defaults = {
- prefix: config.prefix,
- domain: config.domain,
- path: config.path,
- expires: config.expires,
- secure: false
- };
+var config = require( './config.json' ),
+ defaults = {
+ prefix: config.prefix,
+ domain: config.domain,
+ path: config.path,
+ expires: config.expires,
+ secure: false
+ };
+
+/**
+ * Manage cookies in a way that is syntactically and functionally similar
+ * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP.
+ *
+ * @author Sam Smith <samsmith@wikimedia.org>
+ * @author Matthew Flaschen <mflaschen@wikimedia.org>
+ *
+ * @class mw.cookie
+ * @singleton
+ */
+mw.cookie = {
/**
- * Manage cookies in a way that is syntactically and functionally similar
- * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP.
+ * Set or delete a cookie.
*
- * @author Sam Smith <samsmith@wikimedia.org>
- * @author Matthew Flaschen <mflaschen@wikimedia.org>
+ * **Note:** If explicitly passing `null` or `undefined` for an options key,
+ * that will override the default. This is natural in JavaScript, but noted
+ * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method
+ * in PHP.
*
- * @class mw.cookie
- * @singleton
+ * @param {string} key
+ * @param {string|null} value Value of cookie. If `value` is `null` then this method will
+ * instead remove a cookie by name of `key`.
+ * @param {Object|Date|number} [options] Options object, or expiry date
+ * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie,
+ * or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set.
+ * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key
+ * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie
+ * @param {string} [options.path=wgCookiePath] The path attribute of the cookie
+ * @param {boolean} [options.secure=false] Whether or not to include the secure attribute.
+ * (Does **not** use the wgCookieSecure configuration variable)
*/
- mw.cookie = {
+ set: function ( key, value, options ) {
+ var date;
- /**
- * Set or delete a cookie.
- *
- * **Note:** If explicitly passing `null` or `undefined` for an options key,
- * that will override the default. This is natural in JavaScript, but noted
- * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method
- * in PHP.
- *
- * @param {string} key
- * @param {string|null} value Value of cookie. If `value` is `null` then this method will
- * instead remove a cookie by name of `key`.
- * @param {Object|Date|number} [options] Options object, or expiry date
- * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie,
- * or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set.
- * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key
- * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie
- * @param {string} [options.path=wgCookiePath] The path attribute of the cookie
- * @param {boolean} [options.secure=false] Whether or not to include the secure attribute.
- * (Does **not** use the wgCookieSecure configuration variable)
- */
- set: function ( key, value, options ) {
- var date;
+ // The 'options' parameter may be a shortcut for the expiry.
+ if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) {
+ options = { expires: options };
+ }
+ // Apply defaults
+ options = $.extend( {}, defaults, options );
- // The 'options' parameter may be a shortcut for the expiry.
- if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) {
- options = { expires: options };
- }
- // Apply defaults
- options = $.extend( {}, defaults, options );
+ // Handle prefix
+ key = options.prefix + key;
+ // Don't pass invalid option to $.cookie
+ delete options.prefix;
- // Handle prefix
- key = options.prefix + key;
- // Don't pass invalid option to $.cookie
- delete options.prefix;
+ if ( !options.expires ) {
+ // Session cookie (null or zero)
+ // Normalize to absent (undefined) for $.cookie.
+ delete options.expires;
+ } else if ( typeof options.expires === 'number' ) {
+ // Lifetime in seconds
+ date = new Date();
+ date.setTime( Number( date ) + ( options.expires * 1000 ) );
+ options.expires = date;
+ }
- if ( !options.expires ) {
- // Session cookie (null or zero)
- // Normalize to absent (undefined) for $.cookie.
- delete options.expires;
- } else if ( typeof options.expires === 'number' ) {
- // Lifetime in seconds
- date = new Date();
- date.setTime( Number( date ) + ( options.expires * 1000 ) );
- options.expires = date;
- }
+ if ( value !== null ) {
+ value = String( value );
+ }
- if ( value !== null ) {
- value = String( value );
- }
+ $.cookie( key, value, options );
+ },
- $.cookie( key, value, options );
- },
+ /**
+ * Get the value of a cookie.
+ *
+ * @param {string} key
+ * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is
+ * `undefined` or `null`, then `wgCookiePrefix` is used
+ * @param {Mixed} [defaultValue=null]
+ * @return {string|null|Mixed} If the cookie exists, then the value of the
+ * cookie, otherwise `defaultValue`
+ */
+ get: function ( key, prefix, defaultValue ) {
+ var result;
- /**
- * Get the value of a cookie.
- *
- * @param {string} key
- * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is
- * `undefined` or `null`, then `wgCookiePrefix` is used
- * @param {Mixed} [defaultValue=null]
- * @return {string|null|Mixed} If the cookie exists, then the value of the
- * cookie, otherwise `defaultValue`
- */
- get: function ( key, prefix, defaultValue ) {
- var result;
+ if ( prefix === undefined || prefix === null ) {
+ prefix = defaults.prefix;
+ }
- if ( prefix === undefined || prefix === null ) {
- prefix = defaults.prefix;
- }
+ // Was defaultValue omitted?
+ if ( arguments.length < 3 ) {
+ defaultValue = null;
+ }
- // Was defaultValue omitted?
- if ( arguments.length < 3 ) {
- defaultValue = null;
- }
+ result = $.cookie( prefix + key );
- result = $.cookie( prefix + key );
+ return result !== null ? result : defaultValue;
+ }
+};
- return result !== null ? result : defaultValue;
+if ( window.QUnit ) {
+ module.exports = {
+ setDefaults: function ( value ) {
+ var prev = defaults;
+ defaults = value;
+ return prev;
}
};
-
- if ( window.QUnit ) {
- module.exports = {
- setDefaults: function ( value ) {
- var prev = defaults;
- defaults = value;
- return prev;
- }
- };
- }
-}() );
+}
--- /dev/null
+{
+ "parserOptions": {
+ "sourceType": "module"
+ }
+}
* @author neilk@wikimedia.org
* @author mflaschen@wikimedia.org
*/
-( function () {
- /**
- * @class mw.jqueryMsg
- * @singleton
- */
- var oldParser,
- slice = Array.prototype.slice,
- parserDefaults = {
- // Magic words and their expansions. Server-side data is added to this below.
- magic: {
- PAGENAME: mw.config.get( 'wgPageName' ),
- PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
- },
- // Whitelist for allowed HTML elements in wikitext.
- // Self-closing tags are not currently supported.
- // Filled in with server-side data below
- allowedHtmlElements: [],
- // Key tag name, value allowed attributes for that tag.
- // See Sanitizer::setupAttributeWhitelist
- allowedHtmlCommonAttributes: [
- // HTML
- 'id',
- 'class',
- 'style',
- 'lang',
- 'dir',
- 'title',
-
- // WAI-ARIA
- 'role'
- ],
-
- // Attributes allowed for specific elements.
- // Key is element name in lower case
- // Value is array of allowed attributes for that element
- allowedHtmlAttributesByElement: {},
- messages: mw.messages,
- language: mw.language,
-
- // Same meaning as in mediawiki.js.
- //
- // Only 'text', 'parse', and 'escaped' are supported, and the
- // actual escaping for 'escaped' is done by other code (generally
- // through mediawiki.js).
- //
- // However, note that this default only
- // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
- // is 'text', including when it uses jqueryMsg.
- format: 'parse'
- };
-
- // Add in server-side data (allowedHtmlElements and magic words)
- $.extend( true, parserDefaults, require( './parserDefaults.json' ) );
+/**
+ * @class mw.jqueryMsg
+ * @singleton
+ */
+
+var oldParser,
+ slice = Array.prototype.slice,
+ parserDefaults = {
+ // Magic words and their expansions. Server-side data is added to this below.
+ magic: {
+ PAGENAME: mw.config.get( 'wgPageName' ),
+ PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
+ },
+ // Whitelist for allowed HTML elements in wikitext.
+ // Self-closing tags are not currently supported.
+ // Filled in with server-side data below
+ allowedHtmlElements: [],
+ // Key tag name, value allowed attributes for that tag.
+ // See Sanitizer::setupAttributeWhitelist
+ allowedHtmlCommonAttributes: [
+ // HTML
+ 'id',
+ 'class',
+ 'style',
+ 'lang',
+ 'dir',
+ 'title',
+
+ // WAI-ARIA
+ 'role'
+ ],
+
+ // Attributes allowed for specific elements.
+ // Key is element name in lower case
+ // Value is array of allowed attributes for that element
+ allowedHtmlAttributesByElement: {},
+ messages: mw.messages,
+ language: mw.language,
+
+ // Same meaning as in mediawiki.js.
+ //
+ // Only 'text', 'parse', and 'escaped' are supported, and the
+ // actual escaping for 'escaped' is done by other code (generally
+ // through mediawiki.js).
+ //
+ // However, note that this default only
+ // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
+ // is 'text', including when it uses jqueryMsg.
+ format: 'parse'
+ };
- /**
- * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
- * convert what it detects as an htmlString to an element.
- *
- * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
- * new parent.
- *
- * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
- *
- * @private
- * @param {jQuery} $parent Parent node wrapped by jQuery
- * @param {Object|string|Array} children What to append, with the same possible types as jQuery
- * @return {jQuery} $parent
- */
- function appendWithoutParsing( $parent, children ) {
- var i, len;
+// Add in server-side data (allowedHtmlElements and magic words)
+$.extend( true, parserDefaults, require( './parserDefaults.json' ) );
+
+/**
+ * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
+ * convert what it detects as an htmlString to an element.
+ *
+ * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
+ * new parent.
+ *
+ * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
+ *
+ * @private
+ * @param {jQuery} $parent Parent node wrapped by jQuery
+ * @param {Object|string|Array} children What to append, with the same possible types as jQuery
+ * @return {jQuery} $parent
+ */
+function appendWithoutParsing( $parent, children ) {
+ var i, len;
+
+ if ( !Array.isArray( children ) ) {
+ children = [ children ];
+ }
- if ( !Array.isArray( children ) ) {
- children = [ children ];
+ for ( i = 0, len = children.length; i < len; i++ ) {
+ if ( typeof children[ i ] !== 'object' ) {
+ children[ i ] = document.createTextNode( children[ i ] );
}
-
- for ( i = 0, len = children.length; i < len; i++ ) {
- if ( typeof children[ i ] !== 'object' ) {
- children[ i ] = document.createTextNode( children[ i ] );
- }
- if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
- children[ i ] = children[ i ].contents();
- }
+ if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ children[ i ] = children[ i ].contents();
}
-
- return $parent.append( children );
}
- /**
- * Decodes the main HTML entities, those encoded by mw.html.escape.
- *
- * @private
- * @param {string} encoded Encoded string
- * @return {string} String with those entities decoded
- */
- function decodePrimaryHtmlEntities( encoded ) {
- return encoded
- .replace( /'/g, '\'' )
- .replace( /"/g, '"' )
- .replace( /</g, '<' )
- .replace( />/g, '>' )
- .replace( /&/g, '&' );
+ return $parent.append( children );
+}
+
+/**
+ * Decodes the main HTML entities, those encoded by mw.html.escape.
+ *
+ * @private
+ * @param {string} encoded Encoded string
+ * @return {string} String with those entities decoded
+ */
+function decodePrimaryHtmlEntities( encoded ) {
+ return encoded
+ .replace( /'/g, '\'' )
+ .replace( /"/g, '"' )
+ .replace( /</g, '<' )
+ .replace( />/g, '>' )
+ .replace( /&/g, '&' );
+}
+
+/**
+ * Turn input into a string.
+ *
+ * @private
+ * @param {string|jQuery} input
+ * @return {string} Textual value of input
+ */
+function textify( input ) {
+ if ( input instanceof $ ) {
+ input = input.text();
}
-
- /**
- * Turn input into a string.
- *
- * @private
- * @param {string|jQuery} input
- * @return {string} Textual value of input
- */
- function textify( input ) {
- if ( input instanceof $ ) {
- input = input.text();
+ return String( input );
+}
+
+/**
+ * Given parser options, return a function that parses a key and replacements, returning jQuery object
+ *
+ * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
+ * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
+ * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
+ *
+ * @private
+ * @param {Object} options Parser options
+ * @return {Function}
+ * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
+ * @return {jQuery} return.return
+ */
+function getFailableParserFn( options ) {
+ return function ( args ) {
+ var fallback,
+ parser = new mw.jqueryMsg.Parser( options ),
+ key = args[ 0 ],
+ argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
+ try {
+ return parser.parse( key, argsArray );
+ } catch ( e ) {
+ fallback = parser.settings.messages.get( key );
+ mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
+ mw.track( 'mediawiki.jqueryMsg.error', {
+ messageKey: key,
+ errorMessage: e.message
+ } );
+ return $( '<span>' ).text( fallback );
}
- return String( input );
+ };
+}
+
+mw.jqueryMsg = {};
+
+/**
+ * Initialize parser defaults.
+ *
+ * ResourceLoaderJqueryMsgModule calls this to provide default values from
+ * Sanitizer.php for allowed HTML elements. To override this data for individual
+ * parsers, pass the relevant options to mw.jqueryMsg.Parser.
+ *
+ * @private
+ * @param {Object} data New data to extend parser defaults with
+ * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
+ */
+mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
+ if ( deep ) {
+ $.extend( true, parserDefaults, data );
+ } else {
+ $.extend( parserDefaults, data );
}
-
- /**
- * Given parser options, return a function that parses a key and replacements, returning jQuery object
- *
- * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
- * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
- * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
- *
- * @private
- * @param {Object} options Parser options
- * @return {Function}
- * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
- * @return {jQuery} return.return
- */
- function getFailableParserFn( options ) {
- return function ( args ) {
- var fallback,
- parser = new mw.jqueryMsg.Parser( options ),
- key = args[ 0 ],
- argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
- try {
- return parser.parse( key, argsArray );
- } catch ( e ) {
- fallback = parser.settings.messages.get( key );
- mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
- mw.track( 'mediawiki.jqueryMsg.error', {
- messageKey: key,
- errorMessage: e.message
- } );
- return $( '<span>' ).text( fallback );
- }
- };
+};
+
+/**
+ * Get current parser defaults.
+ *
+ * Primarily used for the unit test. Returns a copy.
+ *
+ * @private
+ * @return {Object}
+ */
+mw.jqueryMsg.getParserDefaults = function () {
+ return $.extend( {}, parserDefaults );
+};
+
+/**
+ * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
+ *
+ * Example:
+ *
+ * var format = mediaWiki.jqueryMsg.getMessageFunction( options );
+ * $( '#example' ).text( format( 'hello-user', username ) );
+ *
+ * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
+ * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
+ * from a time when the parser used by `mw.message` was not extendable.
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ * somefunction( a, b, c, d )
+ * is equivalent to
+ * somefunction( a, [b, c, d] )
+ *
+ * @param {Object} options parser options
+ * @return {Function} Function The message formatter
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {string} return.return Rendered HTML.
+ */
+mw.jqueryMsg.getMessageFunction = function ( options ) {
+ var failableParserFn, format;
+
+ if ( options && options.format !== undefined ) {
+ format = options.format;
+ } else {
+ format = parserDefaults.format;
}
- mw.jqueryMsg = {};
-
- /**
- * Initialize parser defaults.
- *
- * ResourceLoaderJqueryMsgModule calls this to provide default values from
- * Sanitizer.php for allowed HTML elements. To override this data for individual
- * parsers, pass the relevant options to mw.jqueryMsg.Parser.
- *
- * @private
- * @param {Object} data New data to extend parser defaults with
- * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
- */
- mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
- if ( deep ) {
- $.extend( true, parserDefaults, data );
+ return function () {
+ var failableResult;
+ if ( !failableParserFn ) {
+ failableParserFn = getFailableParserFn( options );
+ }
+ failableResult = failableParserFn( arguments );
+ if ( format === 'text' || format === 'escaped' ) {
+ return failableResult.text();
} else {
- $.extend( parserDefaults, data );
+ return failableResult.html();
}
};
-
+};
+
+/**
+ * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
+ * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
+ * e.g.
+ *
+ * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
+ * var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
+ * $( 'p#headline' ).msg( 'hello-user', $userlink );
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ * somefunction( a, b, c, d )
+ * is equivalent to
+ * somefunction( a, [b, c, d] )
+ *
+ * We append to 'this', which in a jQuery plugin context will be the selected elements.
+ *
+ * @param {Object} options Parser options
+ * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {jQuery} return.return
+ */
+mw.jqueryMsg.getPlugin = function ( options ) {
+ var failableParserFn;
+
+ return function () {
+ var $target;
+ if ( !failableParserFn ) {
+ failableParserFn = getFailableParserFn( options );
+ }
+ $target = this.empty();
+ appendWithoutParsing( $target, failableParserFn( arguments ) );
+ return $target;
+ };
+};
+
+/**
+ * The parser itself.
+ * Describes an object, whose primary duty is to .parse() message keys.
+ *
+ * @class
+ * @private
+ * @param {Object} options
+ */
+mw.jqueryMsg.Parser = function ( options ) {
+ this.settings = $.extend( {}, parserDefaults, options );
+ this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
+ this.astCache = {};
+
+ this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
+};
+// Backwards-compatible alias
+// @deprecated since 1.31
+mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
+
+mw.jqueryMsg.Parser.prototype = {
/**
- * Get current parser defaults.
- *
- * Primarily used for the unit test. Returns a copy.
+ * Where the magic happens.
+ * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
+ * If an error is thrown, returns original key, and logs the error
*
- * @private
- * @return {Object}
+ * @param {string} key Message key.
+ * @param {Array} replacements Variable replacements for $1, $2... $n
+ * @return {jQuery}
*/
- mw.jqueryMsg.getParserDefaults = function () {
- return $.extend( {}, parserDefaults );
- };
+ parse: function ( key, replacements ) {
+ var ast = this.getAst( key, replacements );
+ return this.emitter.emit( ast, replacements );
+ },
/**
- * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
- *
- * Example:
- *
- * var format = mediaWiki.jqueryMsg.getMessageFunction( options );
- * $( '#example' ).text( format( 'hello-user', username ) );
- *
- * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
- * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
- * from a time when the parser used by `mw.message` was not extendable.
+ * Fetch the message string associated with a key, return parsed structure. Memoized.
+ * Note that we pass '⧼' + key + '⧽' back for a missing message here.
*
- * N.B. replacements are variadic arguments or an array in second parameter. In other words:
- * somefunction( a, b, c, d )
- * is equivalent to
- * somefunction( a, [b, c, d] )
- *
- * @param {Object} options parser options
- * @return {Function} Function The message formatter
- * @return {string} return.key Message key.
- * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
- * @return {string} return.return Rendered HTML.
+ * @param {string} key
+ * @param {Array} replacements Variable replacements for $1, $2... $n
+ * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
*/
- mw.jqueryMsg.getMessageFunction = function ( options ) {
- var failableParserFn, format;
-
- if ( options && options.format !== undefined ) {
- format = options.format;
- } else {
- format = parserDefaults.format;
- }
+ getAst: function ( key, replacements ) {
+ var wikiText;
- return function () {
- var failableResult;
- if ( !failableParserFn ) {
- failableParserFn = getFailableParserFn( options );
- }
- failableResult = failableParserFn( arguments );
- if ( format === 'text' || format === 'escaped' ) {
- return failableResult.text();
+ if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) {
+ if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) {
+ wikiText = '(' + key + '$*)';
} else {
- return failableResult.html();
+ wikiText = this.settings.messages.get( key );
+ if ( typeof wikiText !== 'string' ) {
+ wikiText = '⧼' + key + '⧽';
+ }
}
- };
- };
+ wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements );
+ this.astCache[ key ] = this.wikiTextToAst( wikiText );
+ }
+ return this.astCache[ key ];
+ },
/**
- * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
- * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
- * e.g.
- *
- * $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
- * var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
- * $( 'p#headline' ).msg( 'hello-user', $userlink );
- *
- * N.B. replacements are variadic arguments or an array in second parameter. In other words:
- * somefunction( a, b, c, d )
- * is equivalent to
- * somefunction( a, [b, c, d] )
+ * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
*
- * We append to 'this', which in a jQuery plugin context will be the selected elements.
- *
- * @param {Object} options Parser options
- * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
- * @return {string} return.key Message key.
- * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
- * @return {jQuery} return.return
- */
- mw.jqueryMsg.getPlugin = function ( options ) {
- var failableParserFn;
-
- return function () {
- var $target;
- if ( !failableParserFn ) {
- failableParserFn = getFailableParserFn( options );
- }
- $target = this.empty();
- appendWithoutParsing( $target, failableParserFn( arguments ) );
- return $target;
- };
- };
-
- /**
- * The parser itself.
- * Describes an object, whose primary duty is to .parse() message keys.
+ * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
+ * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
*
- * @class
- * @private
- * @param {Object} options
+ * @param {string} input Message string wikitext
+ * @throws Error
+ * @return {Mixed} abstract syntax tree
*/
- mw.jqueryMsg.Parser = function ( options ) {
- this.settings = $.extend( {}, parserDefaults, options );
- this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
- this.astCache = {};
-
- this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
- };
- // Backwards-compatible alias
- // @deprecated since 1.31
- mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
+ wikiTextToAst: function ( input ) {
+ var pos,
+ regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
+ doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
+ escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
+ whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
+ htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
+ openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
+ templateContents, openTemplate, closeTemplate,
+ nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
+ settings = this.settings,
+ concat = Array.prototype.concat;
+
+ // Indicates current position in input as we parse through it.
+ // Shared among all parsing functions below.
+ pos = 0;
+
+ // =========================================================
+ // parsing combinators - could be a library on its own
+ // =========================================================
- mw.jqueryMsg.Parser.prototype = {
/**
- * Where the magic happens.
- * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
- * If an error is thrown, returns original key, and logs the error
+ * Try parsers until one works, if none work return null
*
- * @param {string} key Message key.
- * @param {Array} replacements Variable replacements for $1, $2... $n
- * @return {jQuery}
+ * @private
+ * @param {Function[]} ps
+ * @return {string|null}
*/
- parse: function ( key, replacements ) {
- var ast = this.getAst( key, replacements );
- return this.emitter.emit( ast, replacements );
- },
+ function choice( ps ) {
+ return function () {
+ var i, result;
+ for ( i = 0; i < ps.length; i++ ) {
+ result = ps[ i ]();
+ if ( result !== null ) {
+ return result;
+ }
+ }
+ return null;
+ };
+ }
/**
- * Fetch the message string associated with a key, return parsed structure. Memoized.
- * Note that we pass '⧼' + key + '⧽' back for a missing message here.
+ * Try several ps in a row, all must succeed or return null.
+ * This is the only eager one.
*
- * @param {string} key
- * @param {Array} replacements Variable replacements for $1, $2... $n
- * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
+ * @private
+ * @param {Function[]} ps
+ * @return {string|null}
*/
- getAst: function ( key, replacements ) {
- var wikiText;
-
- if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) {
- if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) {
- wikiText = '(' + key + '$*)';
- } else {
- wikiText = this.settings.messages.get( key );
- if ( typeof wikiText !== 'string' ) {
- wikiText = '⧼' + key + '⧽';
- }
+ function sequence( ps ) {
+ var i, res,
+ originalPos = pos,
+ result = [];
+ for ( i = 0; i < ps.length; i++ ) {
+ res = ps[ i ]();
+ if ( res === null ) {
+ pos = originalPos;
+ return null;
}
- wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements );
- this.astCache[ key ] = this.wikiTextToAst( wikiText );
+ result.push( res );
}
- return this.astCache[ key ];
- },
+ return result;
+ }
/**
- * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
- *
- * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
- * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
+ * Run the same parser over and over until it fails.
+ * Must succeed a minimum of n times or return null.
*
- * @param {string} input Message string wikitext
- * @throws Error
- * @return {Mixed} abstract syntax tree
+ * @private
+ * @param {number} n
+ * @param {Function} p
+ * @return {string|null}
*/
- wikiTextToAst: function ( input ) {
- var pos,
- regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
- doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
- escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
- whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
- htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
- openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
- templateContents, openTemplate, closeTemplate,
- nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
- settings = this.settings,
- concat = Array.prototype.concat;
-
- // Indicates current position in input as we parse through it.
- // Shared among all parsing functions below.
- pos = 0;
-
- // =========================================================
- // parsing combinators - could be a library on its own
- // =========================================================
-
- /**
- * Try parsers until one works, if none work return null
- *
- * @private
- * @param {Function[]} ps
- * @return {string|null}
- */
- function choice( ps ) {
- return function () {
- var i, result;
- for ( i = 0; i < ps.length; i++ ) {
- result = ps[ i ]();
- if ( result !== null ) {
- return result;
- }
- }
+ function nOrMore( n, p ) {
+ return function () {
+ var originalPos = pos,
+ result = [],
+ parsed = p();
+ while ( parsed !== null ) {
+ result.push( parsed );
+ parsed = p();
+ }
+ if ( result.length < n ) {
+ pos = originalPos;
return null;
- };
- }
-
- /**
- * Try several ps in a row, all must succeed or return null.
- * This is the only eager one.
- *
- * @private
- * @param {Function[]} ps
- * @return {string|null}
- */
- function sequence( ps ) {
- var i, res,
- originalPos = pos,
- result = [];
- for ( i = 0; i < ps.length; i++ ) {
- res = ps[ i ]();
- if ( res === null ) {
- pos = originalPos;
- return null;
- }
- result.push( res );
}
return result;
- }
-
- /**
- * Run the same parser over and over until it fails.
- * Must succeed a minimum of n times or return null.
- *
- * @private
- * @param {number} n
- * @param {Function} p
- * @return {string|null}
- */
- function nOrMore( n, p ) {
- return function () {
- var originalPos = pos,
- result = [],
- parsed = p();
- while ( parsed !== null ) {
- result.push( parsed );
- parsed = p();
- }
- if ( result.length < n ) {
- pos = originalPos;
- return null;
- }
- return result;
- };
- }
+ };
+ }
- /**
- * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
- *
- * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
- * May be some scoping issue
- *
- * @private
- * @param {Function} p
- * @param {Function} fn
- * @return {string|null}
- */
- function transform( p, fn ) {
- return function () {
- var result = p();
- return result === null ? null : fn( result );
- };
- }
+ /**
+ * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
+ *
+ * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
+ * May be some scoping issue
+ *
+ * @private
+ * @param {Function} p
+ * @param {Function} fn
+ * @return {string|null}
+ */
+ function transform( p, fn ) {
+ return function () {
+ var result = p();
+ return result === null ? null : fn( result );
+ };
+ }
- /**
- * Just make parsers out of simpler JS builtin types
- *
- * @private
- * @param {string} s
- * @return {Function}
- * @return {string} return.return
- */
- function makeStringParser( s ) {
- var len = s.length;
- return function () {
- var result = null;
- if ( input.substr( pos, len ) === s ) {
- result = s;
- pos += len;
- }
- return result;
- };
- }
+ /**
+ * Just make parsers out of simpler JS builtin types
+ *
+ * @private
+ * @param {string} s
+ * @return {Function}
+ * @return {string} return.return
+ */
+ function makeStringParser( s ) {
+ var len = s.length;
+ return function () {
+ var result = null;
+ if ( input.substr( pos, len ) === s ) {
+ result = s;
+ pos += len;
+ }
+ return result;
+ };
+ }
- /**
- * Makes a regex parser, given a RegExp object.
- * The regex being passed in should start with a ^ to anchor it to the start
- * of the string.
- *
- * @private
- * @param {RegExp} regex anchored regex
- * @return {Function} function to parse input based on the regex
- */
- function makeRegexParser( regex ) {
- return function () {
- var matches = input.slice( pos ).match( regex );
- if ( matches === null ) {
- return null;
- }
- pos += matches[ 0 ].length;
- return matches[ 0 ];
- };
- }
+ /**
+ * Makes a regex parser, given a RegExp object.
+ * The regex being passed in should start with a ^ to anchor it to the start
+ * of the string.
+ *
+ * @private
+ * @param {RegExp} regex anchored regex
+ * @return {Function} function to parse input based on the regex
+ */
+ function makeRegexParser( regex ) {
+ return function () {
+ var matches = input.slice( pos ).match( regex );
+ if ( matches === null ) {
+ return null;
+ }
+ pos += matches[ 0 ].length;
+ return matches[ 0 ];
+ };
+ }
- // ===================================================================
- // General patterns above this line -- wikitext specific parsers below
- // ===================================================================
-
- // Parsing functions follow. All parsing functions work like this:
- // They don't accept any arguments.
- // Instead, they just operate non destructively on the string 'input'
- // As they can consume parts of the string, they advance the shared variable pos,
- // and return tokens (or whatever else they want to return).
- // some things are defined as closures and other things as ordinary functions
- // converting everything to a closure makes it a lot harder to debug... errors pop up
- // but some debuggers can't tell you exactly where they come from. Also the mutually
- // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
- // This may be because, to save code, memoization was removed
-
- /* eslint-disable no-useless-escape */
- regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
- regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
- regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
- regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
- /* eslint-enable no-useless-escape */
-
- backslash = makeStringParser( '\\' );
- doubleQuote = makeStringParser( '"' );
- singleQuote = makeStringParser( '\'' );
- anyCharacter = makeRegexParser( /^./ );
-
- openHtmlStartTag = makeStringParser( '<' );
- optionalForwardSlash = makeRegexParser( /^\/?/ );
- openHtmlEndTag = makeStringParser( '</' );
- htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
- closeHtmlTag = makeRegexParser( /^\s*>/ );
-
- function escapedLiteral() {
- var result = sequence( [
- backslash,
- anyCharacter
- ] );
- return result === null ? null : result[ 1 ];
- }
- escapedOrLiteralWithoutSpace = choice( [
- escapedLiteral,
- regularLiteralWithoutSpace
- ] );
- escapedOrLiteralWithoutBar = choice( [
- escapedLiteral,
- regularLiteralWithoutBar
- ] );
- escapedOrRegularLiteral = choice( [
- escapedLiteral,
- regularLiteral
+ // ===================================================================
+ // General patterns above this line -- wikitext specific parsers below
+ // ===================================================================
+
+ // Parsing functions follow. All parsing functions work like this:
+ // They don't accept any arguments.
+ // Instead, they just operate non destructively on the string 'input'
+ // As they can consume parts of the string, they advance the shared variable pos,
+ // and return tokens (or whatever else they want to return).
+ // some things are defined as closures and other things as ordinary functions
+ // converting everything to a closure makes it a lot harder to debug... errors pop up
+ // but some debuggers can't tell you exactly where they come from. Also the mutually
+ // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
+ // This may be because, to save code, memoization was removed
+
+ /* eslint-disable no-useless-escape */
+ regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
+ regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
+ regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
+ regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
+ /* eslint-enable no-useless-escape */
+
+ backslash = makeStringParser( '\\' );
+ doubleQuote = makeStringParser( '"' );
+ singleQuote = makeStringParser( '\'' );
+ anyCharacter = makeRegexParser( /^./ );
+
+ openHtmlStartTag = makeStringParser( '<' );
+ optionalForwardSlash = makeRegexParser( /^\/?/ );
+ openHtmlEndTag = makeStringParser( '</' );
+ htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
+ closeHtmlTag = makeRegexParser( /^\s*>/ );
+
+ function escapedLiteral() {
+ var result = sequence( [
+ backslash,
+ anyCharacter
] );
- // Used to define "literals" without spaces, in space-delimited situations
- function literalWithoutSpace() {
- var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
- return result === null ? null : result.join( '' );
- }
- // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
- // it is not a literal in the parameter
- function literalWithoutBar() {
- var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
- return result === null ? null : result.join( '' );
- }
+ return result === null ? null : result[ 1 ];
+ }
+ escapedOrLiteralWithoutSpace = choice( [
+ escapedLiteral,
+ regularLiteralWithoutSpace
+ ] );
+ escapedOrLiteralWithoutBar = choice( [
+ escapedLiteral,
+ regularLiteralWithoutBar
+ ] );
+ escapedOrRegularLiteral = choice( [
+ escapedLiteral,
+ regularLiteral
+ ] );
+ // Used to define "literals" without spaces, in space-delimited situations
+ function literalWithoutSpace() {
+ var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
+ return result === null ? null : result.join( '' );
+ }
+ // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
+ // it is not a literal in the parameter
+ function literalWithoutBar() {
+ var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
+ return result === null ? null : result.join( '' );
+ }
- function literal() {
- var result = nOrMore( 1, escapedOrRegularLiteral )();
- return result === null ? null : result.join( '' );
- }
+ function literal() {
+ var result = nOrMore( 1, escapedOrRegularLiteral )();
+ return result === null ? null : result.join( '' );
+ }
- function curlyBraceTransformExpressionLiteral() {
- var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
- return result === null ? null : result.join( '' );
- }
+ function curlyBraceTransformExpressionLiteral() {
+ var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
+ return result === null ? null : result.join( '' );
+ }
- asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
- htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
- htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
+ asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
+ htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
+ htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
- whitespace = makeRegexParser( /^\s+/ );
- dollar = makeStringParser( '$' );
- digits = makeRegexParser( /^\d+/ );
+ whitespace = makeRegexParser( /^\s+/ );
+ dollar = makeStringParser( '$' );
+ digits = makeRegexParser( /^\d+/ );
- function replacement() {
- var result = sequence( [
- dollar,
- digits
- ] );
- if ( result === null ) {
- return null;
- }
- return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
- }
- openExtlink = makeStringParser( '[' );
- closeExtlink = makeStringParser( ']' );
- // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
- function extlink() {
- var result, parsedResult, target;
- result = null;
- parsedResult = sequence( [
- openExtlink,
- nOrMore( 1, nonWhitespaceExpression ),
- whitespace,
- nOrMore( 1, expression ),
- closeExtlink
- ] );
- if ( parsedResult !== null ) {
- // When the entire link target is a single parameter, we can't use CONCAT, as we allow
- // passing fancy parameters (like a whole jQuery object or a function) to use for the
- // link. Check only if it's a single match, since we can either do CONCAT or not for
- // singles with the same effect.
- target = parsedResult[ 1 ].length === 1 ?
- parsedResult[ 1 ][ 0 ] :
- [ 'CONCAT' ].concat( parsedResult[ 1 ] );
- result = [
- 'EXTLINK',
- target,
- [ 'CONCAT' ].concat( parsedResult[ 3 ] )
- ];
- }
- return result;
- }
- openWikilink = makeStringParser( '[[' );
- closeWikilink = makeStringParser( ']]' );
- pipe = makeStringParser( '|' );
-
- function template() {
- var result = sequence( [
- openTemplate,
- templateContents,
- closeTemplate
- ] );
- return result === null ? null : result[ 1 ];
+ function replacement() {
+ var result = sequence( [
+ dollar,
+ digits
+ ] );
+ if ( result === null ) {
+ return null;
}
-
- function pipedWikilink() {
- var result = sequence( [
- nOrMore( 1, paramExpression ),
- pipe,
- nOrMore( 1, expression )
- ] );
- return result === null ? null : [
- [ 'CONCAT' ].concat( result[ 0 ] ),
- [ 'CONCAT' ].concat( result[ 2 ] )
+ return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
+ }
+ openExtlink = makeStringParser( '[' );
+ closeExtlink = makeStringParser( ']' );
+ // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
+ function extlink() {
+ var result, parsedResult, target;
+ result = null;
+ parsedResult = sequence( [
+ openExtlink,
+ nOrMore( 1, nonWhitespaceExpression ),
+ whitespace,
+ nOrMore( 1, expression ),
+ closeExtlink
+ ] );
+ if ( parsedResult !== null ) {
+ // When the entire link target is a single parameter, we can't use CONCAT, as we allow
+ // passing fancy parameters (like a whole jQuery object or a function) to use for the
+ // link. Check only if it's a single match, since we can either do CONCAT or not for
+ // singles with the same effect.
+ target = parsedResult[ 1 ].length === 1 ?
+ parsedResult[ 1 ][ 0 ] :
+ [ 'CONCAT' ].concat( parsedResult[ 1 ] );
+ result = [
+ 'EXTLINK',
+ target,
+ [ 'CONCAT' ].concat( parsedResult[ 3 ] )
];
}
+ return result;
+ }
+ openWikilink = makeStringParser( '[[' );
+ closeWikilink = makeStringParser( ']]' );
+ pipe = makeStringParser( '|' );
+
+ function template() {
+ var result = sequence( [
+ openTemplate,
+ templateContents,
+ closeTemplate
+ ] );
+ return result === null ? null : result[ 1 ];
+ }
- function unpipedWikilink() {
- var result = sequence( [
- nOrMore( 1, paramExpression )
- ] );
- return result === null ? null : [
- [ 'CONCAT' ].concat( result[ 0 ] )
- ];
- }
+ function pipedWikilink() {
+ var result = sequence( [
+ nOrMore( 1, paramExpression ),
+ pipe,
+ nOrMore( 1, expression )
+ ] );
+ return result === null ? null : [
+ [ 'CONCAT' ].concat( result[ 0 ] ),
+ [ 'CONCAT' ].concat( result[ 2 ] )
+ ];
+ }
- wikilinkContents = choice( [
- pipedWikilink,
- unpipedWikilink
+ function unpipedWikilink() {
+ var result = sequence( [
+ nOrMore( 1, paramExpression )
] );
+ return result === null ? null : [
+ [ 'CONCAT' ].concat( result[ 0 ] )
+ ];
+ }
- function wikilink() {
- var result, parsedResult, parsedLinkContents;
- result = null;
+ wikilinkContents = choice( [
+ pipedWikilink,
+ unpipedWikilink
+ ] );
- parsedResult = sequence( [
- openWikilink,
- wikilinkContents,
- closeWikilink
- ] );
- if ( parsedResult !== null ) {
- parsedLinkContents = parsedResult[ 1 ];
- result = [ 'WIKILINK' ].concat( parsedLinkContents );
- }
- return result;
- }
+ function wikilink() {
+ var result, parsedResult, parsedLinkContents;
+ result = null;
- // TODO: Support data- if appropriate
- function doubleQuotedHtmlAttributeValue() {
- var parsedResult = sequence( [
- doubleQuote,
- htmlDoubleQuoteAttributeValue,
- doubleQuote
- ] );
- return parsedResult === null ? null : parsedResult[ 1 ];
+ parsedResult = sequence( [
+ openWikilink,
+ wikilinkContents,
+ closeWikilink
+ ] );
+ if ( parsedResult !== null ) {
+ parsedLinkContents = parsedResult[ 1 ];
+ result = [ 'WIKILINK' ].concat( parsedLinkContents );
}
+ return result;
+ }
- function singleQuotedHtmlAttributeValue() {
- var parsedResult = sequence( [
- singleQuote,
- htmlSingleQuoteAttributeValue,
- singleQuote
- ] );
- return parsedResult === null ? null : parsedResult[ 1 ];
- }
+ // TODO: Support data- if appropriate
+ function doubleQuotedHtmlAttributeValue() {
+ var parsedResult = sequence( [
+ doubleQuote,
+ htmlDoubleQuoteAttributeValue,
+ doubleQuote
+ ] );
+ return parsedResult === null ? null : parsedResult[ 1 ];
+ }
- function htmlAttribute() {
- var parsedResult = sequence( [
- whitespace,
- asciiAlphabetLiteral,
- htmlAttributeEquals,
- choice( [
- doubleQuotedHtmlAttributeValue,
- singleQuotedHtmlAttributeValue
- ] )
- ] );
- return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
- }
+ function singleQuotedHtmlAttributeValue() {
+ var parsedResult = sequence( [
+ singleQuote,
+ htmlSingleQuoteAttributeValue,
+ singleQuote
+ ] );
+ return parsedResult === null ? null : parsedResult[ 1 ];
+ }
- /**
- * Checks if HTML is allowed
- *
- * @param {string} startTagName HTML start tag name
- * @param {string} endTagName HTML start tag name
- * @param {Object} attributes array of consecutive key value pairs,
- * with index 2 * n being a name and 2 * n + 1 the associated value
- * @return {boolean} true if this is HTML is allowed, false otherwise
- */
- function isAllowedHtml( startTagName, endTagName, attributes ) {
- var i, len, attributeName;
-
- startTagName = startTagName.toLowerCase();
- endTagName = endTagName.toLowerCase();
- if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
- return false;
- }
+ function htmlAttribute() {
+ var parsedResult = sequence( [
+ whitespace,
+ asciiAlphabetLiteral,
+ htmlAttributeEquals,
+ choice( [
+ doubleQuotedHtmlAttributeValue,
+ singleQuotedHtmlAttributeValue
+ ] )
+ ] );
+ return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
+ }
- for ( i = 0, len = attributes.length; i < len; i += 2 ) {
- attributeName = attributes[ i ];
- if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
- ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
- return false;
- }
- }
+ /**
+ * Checks if HTML is allowed
+ *
+ * @param {string} startTagName HTML start tag name
+ * @param {string} endTagName HTML start tag name
+ * @param {Object} attributes array of consecutive key value pairs,
+ * with index 2 * n being a name and 2 * n + 1 the associated value
+ * @return {boolean} true if this is HTML is allowed, false otherwise
+ */
+ function isAllowedHtml( startTagName, endTagName, attributes ) {
+ var i, len, attributeName;
- return true;
+ startTagName = startTagName.toLowerCase();
+ endTagName = endTagName.toLowerCase();
+ if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
+ return false;
}
- function htmlAttributes() {
- var parsedResult = nOrMore( 0, htmlAttribute )();
- // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
- return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
+ for ( i = 0, len = attributes.length; i < len; i += 2 ) {
+ attributeName = attributes[ i ];
+ if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
+ ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
+ return false;
+ }
}
- // Subset of allowed HTML markup.
- // Most elements and many attributes allowed on the server are not supported yet.
- function html() {
- var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
- wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
- startCloseTagPos, endOpenTagPos, endCloseTagPos,
- result = null;
-
- // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
- // 1. open through closeHtmlTag
- // 2. expression
- // 3. openHtmlEnd through close
- // This will allow recording the positions to reconstruct if HTML is to be treated as text.
-
- startOpenTagPos = pos;
- parsedOpenTagResult = sequence( [
- openHtmlStartTag,
- asciiAlphabetLiteral,
- htmlAttributes,
- optionalForwardSlash,
- closeHtmlTag
- ] );
+ return true;
+ }
- if ( parsedOpenTagResult === null ) {
- return null;
- }
+ function htmlAttributes() {
+ var parsedResult = nOrMore( 0, htmlAttribute )();
+ // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
+ return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
+ }
- endOpenTagPos = pos;
- startTagName = parsedOpenTagResult[ 1 ];
+ // Subset of allowed HTML markup.
+ // Most elements and many attributes allowed on the server are not supported yet.
+ function html() {
+ var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
+ wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
+ startCloseTagPos, endOpenTagPos, endCloseTagPos,
+ result = null;
- parsedHtmlContents = nOrMore( 0, expression )();
+ // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
+ // 1. open through closeHtmlTag
+ // 2. expression
+ // 3. openHtmlEnd through close
+ // This will allow recording the positions to reconstruct if HTML is to be treated as text.
+
+ startOpenTagPos = pos;
+ parsedOpenTagResult = sequence( [
+ openHtmlStartTag,
+ asciiAlphabetLiteral,
+ htmlAttributes,
+ optionalForwardSlash,
+ closeHtmlTag
+ ] );
- startCloseTagPos = pos;
- parsedCloseTagResult = sequence( [
- openHtmlEndTag,
- asciiAlphabetLiteral,
- closeHtmlTag
- ] );
+ if ( parsedOpenTagResult === null ) {
+ return null;
+ }
- if ( parsedCloseTagResult === null ) {
- // Closing tag failed. Return the start tag and contents.
- return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
- .concat( parsedHtmlContents );
- }
+ endOpenTagPos = pos;
+ startTagName = parsedOpenTagResult[ 1 ];
- endCloseTagPos = pos;
- endTagName = parsedCloseTagResult[ 1 ];
- wrappedAttributes = parsedOpenTagResult[ 2 ];
- attributes = wrappedAttributes.slice( 1 );
- if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
- result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
- .concat( parsedHtmlContents );
- } else {
- // HTML is not allowed, so contents will remain how
- // it was, while HTML markup at this level will be
- // treated as text
- // E.g. assuming script tags are not allowed:
- //
- // <script>[[Foo|bar]]</script>
- //
- // results in '<script>' and '</script>'
- // (not treated as an HTML tag), surrounding a fully
- // parsed HTML link.
- //
- // Concatenate everything from the tag, flattening the contents.
- result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
- .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
- }
+ parsedHtmlContents = nOrMore( 0, expression )();
- return result;
+ startCloseTagPos = pos;
+ parsedCloseTagResult = sequence( [
+ openHtmlEndTag,
+ asciiAlphabetLiteral,
+ closeHtmlTag
+ ] );
+
+ if ( parsedCloseTagResult === null ) {
+ // Closing tag failed. Return the start tag and contents.
+ return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+ .concat( parsedHtmlContents );
}
- // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
- function nowiki() {
- var parsedResult, plainText,
- result = null;
+ endCloseTagPos = pos;
+ endTagName = parsedCloseTagResult[ 1 ];
+ wrappedAttributes = parsedOpenTagResult[ 2 ];
+ attributes = wrappedAttributes.slice( 1 );
+ if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
+ result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
+ .concat( parsedHtmlContents );
+ } else {
+ // HTML is not allowed, so contents will remain how
+ // it was, while HTML markup at this level will be
+ // treated as text
+ // E.g. assuming script tags are not allowed:
+ //
+ // <script>[[Foo|bar]]</script>
+ //
+ // results in '<script>' and '</script>'
+ // (not treated as an HTML tag), surrounding a fully
+ // parsed HTML link.
+ //
+ // Concatenate everything from the tag, flattening the contents.
+ result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+ .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
+ }
- parsedResult = sequence( [
- makeStringParser( '<nowiki>' ),
- // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
- makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
- makeStringParser( '</nowiki>' )
- ] );
- if ( parsedResult !== null ) {
- plainText = parsedResult[ 1 ];
- result = [ 'CONCAT' ].concat( plainText );
- }
+ return result;
+ }
- return result;
+ // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
+ function nowiki() {
+ var parsedResult, plainText,
+ result = null;
+
+ parsedResult = sequence( [
+ makeStringParser( '<nowiki>' ),
+ // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
+ makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
+ makeStringParser( '</nowiki>' )
+ ] );
+ if ( parsedResult !== null ) {
+ plainText = parsedResult[ 1 ];
+ result = [ 'CONCAT' ].concat( plainText );
}
- templateName = transform(
- // see $wgLegalTitleChars
- // not allowing : due to the need to catch "PLURAL:$1"
- makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
- function ( result ) { return result.toString(); }
- );
- function templateParam() {
- var expr, result;
- result = sequence( [
- pipe,
- nOrMore( 0, paramExpression )
- ] );
- if ( result === null ) {
- return null;
- }
- expr = result[ 1 ];
- // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
- return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
+ return result;
+ }
+
+ templateName = transform(
+ // see $wgLegalTitleChars
+ // not allowing : due to the need to catch "PLURAL:$1"
+ makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
+ function ( result ) { return result.toString(); }
+ );
+ function templateParam() {
+ var expr, result;
+ result = sequence( [
+ pipe,
+ nOrMore( 0, paramExpression )
+ ] );
+ if ( result === null ) {
+ return null;
}
+ expr = result[ 1 ];
+ // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
+ return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
+ }
- function templateWithReplacement() {
- var result = sequence( [
- templateName,
- colon,
- replacement
+ function templateWithReplacement() {
+ var result = sequence( [
+ templateName,
+ colon,
+ replacement
+ ] );
+ return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+ }
+ function templateWithOutReplacement() {
+ var result = sequence( [
+ templateName,
+ colon,
+ paramExpression
+ ] );
+ return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+ }
+ function templateWithOutFirstParameter() {
+ var result = sequence( [
+ templateName,
+ colon
+ ] );
+ return result === null ? null : [ result[ 0 ], '' ];
+ }
+ colon = makeStringParser( ':' );
+ templateContents = choice( [
+ function () {
+ var res = sequence( [
+ // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
+ // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
+ choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
+ nOrMore( 0, templateParam )
] );
- return result === null ? null : [ result[ 0 ], result[ 2 ] ];
- }
- function templateWithOutReplacement() {
- var result = sequence( [
+ return res === null ? null : res[ 0 ].concat( res[ 1 ] );
+ },
+ function () {
+ var res = sequence( [
templateName,
- colon,
- paramExpression
+ nOrMore( 0, templateParam )
] );
- return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+ if ( res === null ) {
+ return null;
+ }
+ return [ res[ 0 ] ].concat( res[ 1 ] );
}
- function templateWithOutFirstParameter() {
- var result = sequence( [
- templateName,
- colon
- ] );
- return result === null ? null : [ result[ 0 ], '' ];
+ ] );
+ openTemplate = makeStringParser( '{{' );
+ closeTemplate = makeStringParser( '}}' );
+ nonWhitespaceExpression = choice( [
+ template,
+ wikilink,
+ extlink,
+ replacement,
+ literalWithoutSpace
+ ] );
+ paramExpression = choice( [
+ template,
+ wikilink,
+ extlink,
+ replacement,
+ literalWithoutBar
+ ] );
+
+ expression = choice( [
+ template,
+ wikilink,
+ extlink,
+ replacement,
+ nowiki,
+ html,
+ literal
+ ] );
+
+ // Used when only {{-transformation is wanted, for 'text'
+ // or 'escaped' formats
+ curlyBraceTransformExpression = choice( [
+ template,
+ replacement,
+ curlyBraceTransformExpressionLiteral
+ ] );
+
+ /**
+ * Starts the parse
+ *
+ * @param {Function} rootExpression Root parse function
+ * @return {Array|null}
+ */
+ function start( rootExpression ) {
+ var result = nOrMore( 0, rootExpression )();
+ if ( result === null ) {
+ return null;
}
- colon = makeStringParser( ':' );
- templateContents = choice( [
- function () {
- var res = sequence( [
- // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
- // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
- choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
- nOrMore( 0, templateParam )
- ] );
- return res === null ? null : res[ 0 ].concat( res[ 1 ] );
- },
- function () {
- var res = sequence( [
- templateName,
- nOrMore( 0, templateParam )
- ] );
- if ( res === null ) {
- return null;
- }
- return [ res[ 0 ] ].concat( res[ 1 ] );
- }
- ] );
- openTemplate = makeStringParser( '{{' );
- closeTemplate = makeStringParser( '}}' );
- nonWhitespaceExpression = choice( [
- template,
- wikilink,
- extlink,
- replacement,
- literalWithoutSpace
- ] );
- paramExpression = choice( [
- template,
- wikilink,
- extlink,
- replacement,
- literalWithoutBar
- ] );
+ return [ 'CONCAT' ].concat( result );
+ }
+ // everything above this point is supposed to be stateless/static, but
+ // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
+ // finally let's do some actual work...
- expression = choice( [
- template,
- wikilink,
- extlink,
- replacement,
- nowiki,
- html,
- literal
- ] );
+ result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
- // Used when only {{-transformation is wanted, for 'text'
- // or 'escaped' formats
- curlyBraceTransformExpression = choice( [
- template,
- replacement,
- curlyBraceTransformExpressionLiteral
- ] );
+ /*
+ * For success, the p must have gotten to the end of the input
+ * and returned a non-null.
+ * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
+ */
+ if ( result === null || pos !== input.length ) {
+ throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
+ }
+ return result;
+ }
- /**
- * Starts the parse
- *
- * @param {Function} rootExpression Root parse function
- * @return {Array|null}
- */
- function start( rootExpression ) {
- var result = nOrMore( 0, rootExpression )();
- if ( result === null ) {
- return null;
+};
+
+/**
+ * Class that primarily exists to emit HTML from parser ASTs.
+ *
+ * @private
+ * @class
+ * @param {Object} language
+ * @param {Object} magic
+ */
+mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
+ var jmsg = this;
+ this.language = language;
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( magic, function ( key, val ) {
+ jmsg[ key.toLowerCase() ] = function () {
+ return val;
+ };
+ } );
+
+ /**
+ * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
+ * Walk entire node structure, applying replacements and template functions when appropriate
+ *
+ * @param {Mixed} node Abstract syntax tree (top node or subnode)
+ * @param {Array} replacements for $1, $2, ... $n
+ * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
+ */
+ this.emit = function ( node, replacements ) {
+ var ret, subnodes, operation,
+ jmsg = this;
+ switch ( typeof node ) {
+ case 'string':
+ case 'number':
+ ret = node;
+ break;
+ // typeof returns object for arrays
+ case 'object':
+ // node is an array of nodes
+ // eslint-disable-next-line no-jquery/no-map-util
+ subnodes = $.map( node.slice( 1 ), function ( n ) {
+ return jmsg.emit( n, replacements );
+ } );
+ operation = node[ 0 ].toLowerCase();
+ if ( typeof jmsg[ operation ] === 'function' ) {
+ ret = jmsg[ operation ]( subnodes, replacements );
+ } else {
+ throw new Error( 'Unknown operation "' + operation + '"' );
}
- return [ 'CONCAT' ].concat( result );
- }
- // everything above this point is supposed to be stateless/static, but
- // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
- // finally let's do some actual work...
-
- result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
-
- /*
- * For success, the p must have gotten to the end of the input
- * and returned a non-null.
- * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
- */
- if ( result === null || pos !== input.length ) {
- throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
- }
- return result;
+ break;
+ case 'undefined':
+ // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
+ // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
+ // The logical thing is probably to return the empty string here when we encounter undefined.
+ ret = '';
+ break;
+ default:
+ throw new Error( 'Unexpected type in AST: ' + typeof node );
}
-
+ return ret;
};
-
+};
+
+// For everything in input that follows double-open-curly braces, there should be an equivalent parser
+// function. For instance {{PLURAL ... }} will be processed by 'plural'.
+// If you have 'magic words' then configure the parser to have them upon creation.
+//
+// An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
+// Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
+mw.jqueryMsg.HtmlEmitter.prototype = {
/**
- * Class that primarily exists to emit HTML from parser ASTs.
+ * Parsing has been applied depth-first we can assume that all nodes here are single nodes
+ * Must return a single node to parents -- a jQuery with synthetic span
+ * However, unwrap any other synthetic spans in our children and pass them upwards
*
- * @private
- * @class
- * @param {Object} language
- * @param {Object} magic
+ * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
+ * @return {jQuery}
*/
- mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
- var jmsg = this;
- this.language = language;
+ concat: function ( nodes ) {
+ var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
// eslint-disable-next-line no-jquery/no-each-util
- $.each( magic, function ( key, val ) {
- jmsg[ key.toLowerCase() ] = function () {
- return val;
- };
+ $.each( nodes, function ( i, node ) {
+ // Let jQuery append nodes, arrays of nodes and jQuery objects
+ // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
+ appendWithoutParsing( $span, node );
} );
+ return $span;
+ },
- /**
- * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
- * Walk entire node structure, applying replacements and template functions when appropriate
- *
- * @param {Mixed} node Abstract syntax tree (top node or subnode)
- * @param {Array} replacements for $1, $2, ... $n
- * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
- */
- this.emit = function ( node, replacements ) {
- var ret, subnodes, operation,
- jmsg = this;
- switch ( typeof node ) {
- case 'string':
- case 'number':
- ret = node;
- break;
- // typeof returns object for arrays
- case 'object':
- // node is an array of nodes
- // eslint-disable-next-line no-jquery/no-map-util
- subnodes = $.map( node.slice( 1 ), function ( n ) {
- return jmsg.emit( n, replacements );
- } );
- operation = node[ 0 ].toLowerCase();
- if ( typeof jmsg[ operation ] === 'function' ) {
- ret = jmsg[ operation ]( subnodes, replacements );
- } else {
- throw new Error( 'Unknown operation "' + operation + '"' );
- }
- break;
- case 'undefined':
- // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
- // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
- // The logical thing is probably to return the empty string here when we encounter undefined.
- ret = '';
- break;
- default:
- throw new Error( 'Unexpected type in AST: ' + typeof node );
- }
- return ret;
- };
- };
-
- // For everything in input that follows double-open-curly braces, there should be an equivalent parser
- // function. For instance {{PLURAL ... }} will be processed by 'plural'.
- // If you have 'magic words' then configure the parser to have them upon creation.
- //
- // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
- // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
- mw.jqueryMsg.HtmlEmitter.prototype = {
- /**
- * Parsing has been applied depth-first we can assume that all nodes here are single nodes
- * Must return a single node to parents -- a jQuery with synthetic span
- * However, unwrap any other synthetic spans in our children and pass them upwards
- *
- * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
- * @return {jQuery}
- */
- concat: function ( nodes ) {
- var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( nodes, function ( i, node ) {
- // Let jQuery append nodes, arrays of nodes and jQuery objects
- // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
- appendWithoutParsing( $span, node );
- } );
- return $span;
- },
+ /**
+ * Return escaped replacement of correct index, or string if unavailable.
+ * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
+ * if the specified parameter is not found return the same string
+ * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
+ *
+ * TODO: Throw error if nodes.length > 1 ?
+ *
+ * @param {Array} nodes List of one element, integer, n >= 0
+ * @param {Array} replacements List of at least n strings
+ * @return {string|jQuery} replacement
+ */
+ replace: function ( nodes, replacements ) {
+ var index = parseInt( nodes[ 0 ], 10 );
- /**
- * Return escaped replacement of correct index, or string if unavailable.
- * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
- * if the specified parameter is not found return the same string
- * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
- *
- * TODO: Throw error if nodes.length > 1 ?
- *
- * @param {Array} nodes List of one element, integer, n >= 0
- * @param {Array} replacements List of at least n strings
- * @return {string|jQuery} replacement
- */
- replace: function ( nodes, replacements ) {
- var index = parseInt( nodes[ 0 ], 10 );
+ if ( index < replacements.length ) {
+ return replacements[ index ];
+ } else {
+ // index not found, fallback to displaying variable
+ return '$' + ( index + 1 );
+ }
+ },
- if ( index < replacements.length ) {
- return replacements[ index ];
- } else {
- // index not found, fallback to displaying variable
- return '$' + ( index + 1 );
- }
- },
+ /**
+ * Transform wiki-link
+ *
+ * TODO:
+ * It only handles basic cases, either no pipe, or a pipe with an explicit
+ * anchor.
+ *
+ * It does not attempt to handle features like the pipe trick.
+ * However, the pipe trick should usually not be present in wikitext retrieved
+ * from the server, since the replacement is done at save time.
+ * It may, though, if the wikitext appears in extension-controlled content.
+ *
+ * @param {string[]} nodes
+ * @return {jQuery}
+ */
+ wikilink: function ( nodes ) {
+ var page, anchor, url, $el;
+
+ page = textify( nodes[ 0 ] );
+ // Strip leading ':', which is used to suppress special behavior in wikitext links,
+ // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
+ if ( page.charAt( 0 ) === ':' ) {
+ page = page.slice( 1 );
+ }
+ url = mw.util.getUrl( page );
- /**
- * Transform wiki-link
- *
- * TODO:
- * It only handles basic cases, either no pipe, or a pipe with an explicit
- * anchor.
- *
- * It does not attempt to handle features like the pipe trick.
- * However, the pipe trick should usually not be present in wikitext retrieved
- * from the server, since the replacement is done at save time.
- * It may, though, if the wikitext appears in extension-controlled content.
- *
- * @param {string[]} nodes
- * @return {jQuery}
- */
- wikilink: function ( nodes ) {
- var page, anchor, url, $el;
-
- page = textify( nodes[ 0 ] );
- // Strip leading ':', which is used to suppress special behavior in wikitext links,
- // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
- if ( page.charAt( 0 ) === ':' ) {
- page = page.slice( 1 );
- }
- url = mw.util.getUrl( page );
+ if ( nodes.length === 1 ) {
+ // [[Some Page]] or [[Namespace:Some Page]]
+ anchor = page;
+ } else {
+ // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
+ anchor = nodes[ 1 ];
+ }
- if ( nodes.length === 1 ) {
- // [[Some Page]] or [[Namespace:Some Page]]
- anchor = page;
- } else {
- // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
- anchor = nodes[ 1 ];
- }
+ $el = $( '<a>' ).attr( {
+ title: page,
+ href: url
+ } );
+ return appendWithoutParsing( $el, anchor );
+ },
- $el = $( '<a>' ).attr( {
- title: page,
- href: url
- } );
- return appendWithoutParsing( $el, anchor );
- },
+ /**
+ * Converts array of HTML element key value pairs to object
+ *
+ * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
+ * name and 2 * n + 1 the associated value
+ * @return {Object} Object mapping attribute name to attribute value
+ */
+ htmlattributes: function ( nodes ) {
+ var i, len, mapping = {};
+ for ( i = 0, len = nodes.length; i < len; i += 2 ) {
+ mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
+ }
+ return mapping;
+ },
- /**
- * Converts array of HTML element key value pairs to object
- *
- * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
- * name and 2 * n + 1 the associated value
- * @return {Object} Object mapping attribute name to attribute value
- */
- htmlattributes: function ( nodes ) {
- var i, len, mapping = {};
- for ( i = 0, len = nodes.length; i < len; i += 2 ) {
- mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
- }
- return mapping;
- },
+ /**
+ * Handles an (already-validated) HTML element.
+ *
+ * @param {Array} nodes Nodes to process when creating element
+ * @return {jQuery}
+ */
+ htmlelement: function ( nodes ) {
+ var tagName, attributes, contents, $element;
- /**
- * Handles an (already-validated) HTML element.
- *
- * @param {Array} nodes Nodes to process when creating element
- * @return {jQuery}
- */
- htmlelement: function ( nodes ) {
- var tagName, attributes, contents, $element;
-
- tagName = nodes.shift();
- attributes = nodes.shift();
- contents = nodes;
- $element = $( document.createElement( tagName ) ).attr( attributes );
- return appendWithoutParsing( $element, contents );
- },
+ tagName = nodes.shift();
+ attributes = nodes.shift();
+ contents = nodes;
+ $element = $( document.createElement( tagName ) ).attr( attributes );
+ return appendWithoutParsing( $element, contents );
+ },
- /**
- * Transform parsed structure into external link.
- *
- * The "href" can be:
- * - a jQuery object, treat it as "enclosing" the link text.
- * - a function, treat it as the click handler.
- * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
- *
- * TODO: throw an error if nodes.length > 2 ?
- *
- * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
- * @return {jQuery}
- */
- extlink: function ( nodes ) {
- var $el,
- arg = nodes[ 0 ],
- contents = nodes[ 1 ];
- if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
- $el = arg;
+ /**
+ * Transform parsed structure into external link.
+ *
+ * The "href" can be:
+ * - a jQuery object, treat it as "enclosing" the link text.
+ * - a function, treat it as the click handler.
+ * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
+ *
+ * TODO: throw an error if nodes.length > 2 ?
+ *
+ * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
+ * @return {jQuery}
+ */
+ extlink: function ( nodes ) {
+ var $el,
+ arg = nodes[ 0 ],
+ contents = nodes[ 1 ];
+ if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ $el = arg;
+ } else {
+ $el = $( '<a>' );
+ if ( typeof arg === 'function' ) {
+ $el.attr( {
+ role: 'button',
+ tabindex: 0
+ } ).on( 'click keypress', function ( e ) {
+ if (
+ e.type === 'click' ||
+ e.type === 'keypress' && e.which === 13
+ ) {
+ arg.call( this, e );
+ }
+ } );
} else {
- $el = $( '<a>' );
- if ( typeof arg === 'function' ) {
- $el.attr( {
- role: 'button',
- tabindex: 0
- } ).on( 'click keypress', function ( e ) {
- if (
- e.type === 'click' ||
- e.type === 'keypress' && e.which === 13
- ) {
- arg.call( this, e );
- }
- } );
- } else {
- $el.attr( 'href', textify( arg ) );
- }
+ $el.attr( 'href', textify( arg ) );
}
- return appendWithoutParsing( $el.empty(), contents );
- },
-
- /**
- * Transform parsed structure into pluralization
- * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
- * So convert it back with the current language's convertNumber.
- *
- * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
- * @return {string|jQuery} selected pluralized form according to current language
- */
- plural: function ( nodes ) {
- var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
- explicitPluralForms = {};
+ }
+ return appendWithoutParsing( $el.empty(), contents );
+ },
- count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) );
- forms = nodes.slice( 1 );
- for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
- form = forms[ formIndex ];
-
- if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
- // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
- firstChild = form.contents().get( 0 );
- if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
- firstChildText = firstChild.textContent;
- if ( /^\d+=/.test( firstChildText ) ) {
- explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
- // Use the digit part as key and rest of first text node and
- // rest of child nodes as value.
- firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
- explicitPluralForms[ explicitPluralFormNumber ] = form;
- forms[ formIndex ] = undefined;
- }
+ /**
+ * Transform parsed structure into pluralization
+ * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
+ * So convert it back with the current language's convertNumber.
+ *
+ * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
+ * @return {string|jQuery} selected pluralized form according to current language
+ */
+ plural: function ( nodes ) {
+ var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
+ explicitPluralForms = {};
+
+ count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) );
+ forms = nodes.slice( 1 );
+ for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
+ form = forms[ formIndex ];
+
+ if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
+ firstChild = form.contents().get( 0 );
+ if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
+ firstChildText = firstChild.textContent;
+ if ( /^\d+=/.test( firstChildText ) ) {
+ explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
+ // Use the digit part as key and rest of first text node and
+ // rest of child nodes as value.
+ firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
+ explicitPluralForms[ explicitPluralFormNumber ] = form;
+ forms[ formIndex ] = undefined;
}
- } else if ( /^\d+=/.test( form ) ) {
- // Simple explicit plural forms like 12=a dozen
- explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
- explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
- forms[ formIndex ] = undefined;
}
+ } else if ( /^\d+=/.test( form ) ) {
+ // Simple explicit plural forms like 12=a dozen
+ explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
+ explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
+ forms[ formIndex ] = undefined;
}
+ }
- // Remove explicit plural forms from the forms. They were set undefined in the above loop.
- // eslint-disable-next-line no-jquery/no-map-util
- forms = $.map( forms, function ( form ) {
- return form;
- } );
-
- return this.language.convertPlural( count, forms, explicitPluralForms );
- },
-
- /**
- * Transform parsed structure according to gender.
- *
- * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
- *
- * The first node must be one of:
- * - the mw.user object (or a compatible one)
- * - an empty string - indicating the current user, same effect as passing the mw.user object
- * - a gender string ('male', 'female' or 'unknown')
- *
- * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
- * @return {string|jQuery} Selected gender form according to current language
- */
- gender: function ( nodes ) {
- var gender,
- maybeUser = nodes[ 0 ],
- forms = nodes.slice( 1 );
-
- if ( maybeUser === '' ) {
- maybeUser = mw.user;
- }
-
- // If we are passed a mw.user-like object, check their gender.
- // Otherwise, assume the gender string itself was passed .
- if ( maybeUser && maybeUser.options instanceof mw.Map ) {
- gender = maybeUser.options.get( 'gender' );
- } else {
- gender = textify( maybeUser );
- }
-
- return this.language.gender( gender, forms );
- },
+ // Remove explicit plural forms from the forms. They were set undefined in the above loop.
+ // eslint-disable-next-line no-jquery/no-map-util
+ forms = $.map( forms, function ( form ) {
+ return form;
+ } );
- /**
- * Transform parsed structure into grammar conversion.
- * Invoked by putting `{{grammar:form|word}}` in a message
- *
- * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
- * @return {string|jQuery} selected grammatical form according to current language
- */
- grammar: function ( nodes ) {
- var form = nodes[ 0 ],
- word = nodes[ 1 ];
- // These could be jQuery objects (passed as message parameters),
- // in which case we can't transform them (like rawParams() in PHP).
- if ( typeof form === 'string' && typeof word === 'string' ) {
- return this.language.convertGrammar( word, form );
- }
- return word;
- },
+ return this.language.convertPlural( count, forms, explicitPluralForms );
+ },
- /**
- * Tranform parsed structure into a int: (interface language) message include
- * Invoked by putting `{{int:othermessage}}` into a message
- *
- * TODO Syntax in the included message is not parsed, this seems like a bug?
- *
- * @param {Array} nodes List of nodes
- * @return {string} Other message
- */
- int: function ( nodes ) {
- var msg = textify( nodes[ 0 ] );
- return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
- },
+ /**
+ * Transform parsed structure according to gender.
+ *
+ * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
+ *
+ * The first node must be one of:
+ * - the mw.user object (or a compatible one)
+ * - an empty string - indicating the current user, same effect as passing the mw.user object
+ * - a gender string ('male', 'female' or 'unknown')
+ *
+ * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
+ * @return {string|jQuery} Selected gender form according to current language
+ */
+ gender: function ( nodes ) {
+ var gender,
+ maybeUser = nodes[ 0 ],
+ forms = nodes.slice( 1 );
- /**
- * Get localized namespace name from canonical name or namespace number.
- * Invoked by putting `{{ns:foo}}` into a message
- *
- * @param {Array} nodes List of nodes
- * @return {string} Localized namespace name
- */
- ns: function ( nodes ) {
- var ns = textify( nodes[ 0 ] ).trim();
- if ( !/^\d+$/.test( ns ) ) {
- ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
- }
- ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
- return ns || '';
- },
+ if ( maybeUser === '' ) {
+ maybeUser = mw.user;
+ }
- /**
- * Takes an unformatted number (arab, no group separators and . as decimal separator)
- * and outputs it in the localized digit script and formatted with decimal
- * separator, according to the current language.
- *
- * @param {Array} nodes List of nodes
- * @return {number|string|jQuery} Formatted number
- */
- formatnum: function ( nodes ) {
- var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
- number = nodes[ 0 ];
-
- // These could be jQuery objects (passed as message parameters),
- // in which case we can't transform them (like rawParams() in PHP).
- if ( typeof number === 'string' || typeof number === 'number' ) {
- return this.language.convertNumber( number, isInteger );
- }
- return number;
- },
+ // If we are passed a mw.user-like object, check their gender.
+ // Otherwise, assume the gender string itself was passed .
+ if ( maybeUser && maybeUser.options instanceof mw.Map ) {
+ gender = maybeUser.options.get( 'gender' );
+ } else {
+ gender = textify( maybeUser );
+ }
- /**
- * Lowercase text
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, all in lowercase
- */
- lc: function ( nodes ) {
- return textify( nodes[ 0 ] ).toLowerCase();
- },
+ return this.language.gender( gender, forms );
+ },
- /**
- * Uppercase text
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, all in uppercase
- */
- uc: function ( nodes ) {
- return textify( nodes[ 0 ] ).toUpperCase();
- },
+ /**
+ * Transform parsed structure into grammar conversion.
+ * Invoked by putting `{{grammar:form|word}}` in a message
+ *
+ * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
+ * @return {string|jQuery} selected grammatical form according to current language
+ */
+ grammar: function ( nodes ) {
+ var form = nodes[ 0 ],
+ word = nodes[ 1 ];
+ // These could be jQuery objects (passed as message parameters),
+ // in which case we can't transform them (like rawParams() in PHP).
+ if ( typeof form === 'string' && typeof word === 'string' ) {
+ return this.language.convertGrammar( word, form );
+ }
+ return word;
+ },
- /**
- * Lowercase first letter of input, leaving the rest unchanged
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, with the first character in lowercase
- */
- lcfirst: function ( nodes ) {
- var text = textify( nodes[ 0 ] );
- return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
- },
+ /**
+ * Tranform parsed structure into a int: (interface language) message include
+ * Invoked by putting `{{int:othermessage}}` into a message
+ *
+ * TODO Syntax in the included message is not parsed, this seems like a bug?
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} Other message
+ */
+ int: function ( nodes ) {
+ var msg = textify( nodes[ 0 ] );
+ return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
+ },
- /**
- * Uppercase first letter of input, leaving the rest unchanged
- *
- * @param {Array} nodes List of nodes
- * @return {string} The given text, with the first character in uppercase
- */
- ucfirst: function ( nodes ) {
- var text = textify( nodes[ 0 ] );
- return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
+ /**
+ * Get localized namespace name from canonical name or namespace number.
+ * Invoked by putting `{{ns:foo}}` into a message
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} Localized namespace name
+ */
+ ns: function ( nodes ) {
+ var ns = textify( nodes[ 0 ] ).trim();
+ if ( !/^\d+$/.test( ns ) ) {
+ ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
}
- };
+ ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
+ return ns || '';
+ },
/**
- * @method
- * @member jQuery
- * @see mw.jqueryMsg#getPlugin
+ * Takes an unformatted number (arab, no group separators and . as decimal separator)
+ * and outputs it in the localized digit script and formatted with decimal
+ * separator, according to the current language.
+ *
+ * @param {Array} nodes List of nodes
+ * @return {number|string|jQuery} Formatted number
*/
- $.fn.msg = mw.jqueryMsg.getPlugin();
-
- // Replace the default message parser with jqueryMsg
- oldParser = mw.Message.prototype.parser;
- mw.Message.prototype.parser = function () {
- // Fall back to mw.msg's simple parser where possible
- if (
- // Plain text output always uses the simple parser
- this.format === 'plain' ||
- (
- // jqueryMsg parser is needed for messages containing wikitext
- !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) &&
- // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters
- !this.parameters.some( function ( param ) {
- return param instanceof $ || ( param && param.nodeType !== undefined );
- } )
- )
- ) {
- return oldParser.apply( this );
+ formatnum: function ( nodes ) {
+ var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
+ number = nodes[ 0 ];
+
+ // These could be jQuery objects (passed as message parameters),
+ // in which case we can't transform them (like rawParams() in PHP).
+ if ( typeof number === 'string' || typeof number === 'number' ) {
+ return this.language.convertNumber( number, isInteger );
}
+ return number;
+ },
- if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) {
- this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
- messages: this.map,
- // For format 'escaped', escaping part is handled by mediawiki.js
- format: this.format
- } );
- }
- return this.map[ this.format ]( this.key, this.parameters );
- };
+ /**
+ * Lowercase text
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, all in lowercase
+ */
+ lc: function ( nodes ) {
+ return textify( nodes[ 0 ] ).toLowerCase();
+ },
/**
- * Parse the message to DOM nodes, rather than HTML string like #parse.
+ * Uppercase text
*
- * This method is only available when jqueryMsg is loaded.
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, all in uppercase
+ */
+ uc: function ( nodes ) {
+ return textify( nodes[ 0 ] ).toUpperCase();
+ },
+
+ /**
+ * Lowercase first letter of input, leaving the rest unchanged
*
- * @since 1.27
- * @method parseDom
- * @member mw.Message
- * @return {jQuery}
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, with the first character in lowercase
*/
- mw.Message.prototype.parseDom = ( function () {
- var $wrapper = $( '<div>' );
- return function () {
- return $wrapper.msg( this.key, this.parameters ).contents().detach();
- };
- }() );
+ lcfirst: function ( nodes ) {
+ var text = textify( nodes[ 0 ] );
+ return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
+ },
+
+ /**
+ * Uppercase first letter of input, leaving the rest unchanged
+ *
+ * @param {Array} nodes List of nodes
+ * @return {string} The given text, with the first character in uppercase
+ */
+ ucfirst: function ( nodes ) {
+ var text = textify( nodes[ 0 ] );
+ return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
+ }
+};
+
+/**
+ * @method
+ * @member jQuery
+ * @see mw.jqueryMsg#getPlugin
+ */
+$.fn.msg = mw.jqueryMsg.getPlugin();
+
+// Replace the default message parser with jqueryMsg
+oldParser = mw.Message.prototype.parser;
+mw.Message.prototype.parser = function () {
+ // Fall back to mw.msg's simple parser where possible
+ if (
+ // Plain text output always uses the simple parser
+ this.format === 'plain' ||
+ (
+ // jqueryMsg parser is needed for messages containing wikitext
+ !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) &&
+ // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters
+ !this.parameters.some( function ( param ) {
+ return param instanceof $ || ( param && param.nodeType !== undefined );
+ } )
+ )
+ ) {
+ return oldParser.apply( this );
+ }
+ if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) {
+ this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
+ messages: this.map,
+ // For format 'escaped', escaping part is handled by mediawiki.js
+ format: this.format
+ } );
+ }
+ return this.map[ this.format ]( this.key, this.parameters );
+};
+
+/**
+ * Parse the message to DOM nodes, rather than HTML string like #parse.
+ *
+ * This method is only available when jqueryMsg is loaded.
+ *
+ * @since 1.27
+ * @method parseDom
+ * @member mw.Message
+ * @return {jQuery}
+ */
+mw.Message.prototype.parseDom = ( function () {
+ var $wrapper = $( '<div>' );
+ return function () {
+ return $wrapper.msg( this.key, this.parameters ).contents().detach();
+ };
}() );
--- /dev/null
+{
+ "parserOptions": {
+ "sourceType": "module"
+ }
+}
-( function () {
-
- var byteLength = require( 'mediawiki.String' ).byteLength,
- UriProcessor = require( './UriProcessor.js' ),
- Controller;
-
- /* eslint no-underscore-dangle: "off" */
- /**
- * Controller for the filters in Recent Changes
- * @class mw.rcfilters.Controller
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
- * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
- * @param {Object} config Additional configuration
- * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
- * @cfg {string} daysPreferenceName Preference name for the days filter
- * @cfg {string} limitPreferenceName Preference name for the limit filter
- * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
- * the active filters area
- * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
- * title normalization to separate title subpage/parts into the target= url
- * parameter
- */
- Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
- this.filtersModel = filtersModel;
- this.changesListModel = changesListModel;
- this.savedQueriesModel = savedQueriesModel;
- this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
- this.daysPreferenceName = config.daysPreferenceName;
- this.limitPreferenceName = config.limitPreferenceName;
- this.collapsedPreferenceName = config.collapsedPreferenceName;
- this.normalizeTarget = !!config.normalizeTarget;
-
- this.pollingRate = require( './config.json' ).StructuredChangeFiltersLiveUpdatePollingRate;
-
- this.requestCounter = {};
- this.baseFilterState = {};
- this.uriProcessor = null;
- this.initialized = false;
- this.wereSavedQueriesSaved = false;
-
- this.prevLoggedItems = [];
-
- this.FILTER_CHANGE = 'filterChange';
- this.SHOW_NEW_CHANGES = 'showNewChanges';
- this.LIVE_UPDATE = 'liveUpdate';
- };
-
- /* Initialization */
- OO.initClass( Controller );
-
- /**
- * Initialize the filter and parameter states
- *
- * @param {Array} filterStructure Filter definition and structure for the model
- * @param {Object} [namespaceStructure] Namespace definition
- * @param {Object} [tagList] Tag definition
- * @param {Object} [conditionalViews] Conditional view definition
- */
- Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
- var parsedSavedQueries, pieces,
- displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
- defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
- controller = this,
- views = $.extend( true, {}, conditionalViews ),
- items = [],
- uri = new mw.Uri();
-
- // Prepare views
- if ( namespaceStructure ) {
- items = [];
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( namespaceStructure, function ( namespaceID, label ) {
- // Build and clean up the individual namespace items definition
- items.push( {
- name: namespaceID,
- label: label || mw.msg( 'blanknamespace' ),
- description: '',
- identifiers: [
- mw.Title.isTalkNamespace( namespaceID ) ?
- 'talk' : 'subject'
- ],
- cssClass: 'mw-changeslist-ns-' + namespaceID
- } );
+var byteLength = require( 'mediawiki.String' ).byteLength,
+ UriProcessor = require( './UriProcessor.js' ),
+ Controller;
+
+/* eslint no-underscore-dangle: "off" */
+/**
+ * Controller for the filters in Recent Changes
+ * @class mw.rcfilters.Controller
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {Object} config Additional configuration
+ * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
+ * @cfg {string} daysPreferenceName Preference name for the days filter
+ * @cfg {string} limitPreferenceName Preference name for the limit filter
+ * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
+ * the active filters area
+ * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+ * title normalization to separate title subpage/parts into the target= url
+ * parameter
+ */
+Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
+ this.filtersModel = filtersModel;
+ this.changesListModel = changesListModel;
+ this.savedQueriesModel = savedQueriesModel;
+ this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
+ this.daysPreferenceName = config.daysPreferenceName;
+ this.limitPreferenceName = config.limitPreferenceName;
+ this.collapsedPreferenceName = config.collapsedPreferenceName;
+ this.normalizeTarget = !!config.normalizeTarget;
+
+ this.pollingRate = require( './config.json' ).StructuredChangeFiltersLiveUpdatePollingRate;
+
+ this.requestCounter = {};
+ this.baseFilterState = {};
+ this.uriProcessor = null;
+ this.initialized = false;
+ this.wereSavedQueriesSaved = false;
+
+ this.prevLoggedItems = [];
+
+ this.FILTER_CHANGE = 'filterChange';
+ this.SHOW_NEW_CHANGES = 'showNewChanges';
+ this.LIVE_UPDATE = 'liveUpdate';
+};
+
+/* Initialization */
+OO.initClass( Controller );
+
+/**
+ * Initialize the filter and parameter states
+ *
+ * @param {Array} filterStructure Filter definition and structure for the model
+ * @param {Object} [namespaceStructure] Namespace definition
+ * @param {Object} [tagList] Tag definition
+ * @param {Object} [conditionalViews] Conditional view definition
+ */
+Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
+ var parsedSavedQueries, pieces,
+ displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
+ defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
+ controller = this,
+ views = $.extend( true, {}, conditionalViews ),
+ items = [],
+ uri = new mw.Uri();
+
+ // Prepare views
+ if ( namespaceStructure ) {
+ items = [];
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( namespaceStructure, function ( namespaceID, label ) {
+ // Build and clean up the individual namespace items definition
+ items.push( {
+ name: namespaceID,
+ label: label || mw.msg( 'blanknamespace' ),
+ description: '',
+ identifiers: [
+ mw.Title.isTalkNamespace( namespaceID ) ?
+ 'talk' : 'subject'
+ ],
+ cssClass: 'mw-changeslist-ns-' + namespaceID
} );
+ } );
- views.namespaces = {
+ views.namespaces = {
+ title: mw.msg( 'namespaces' ),
+ trigger: ':',
+ groups: [ {
+ // Group definition (single group)
+ name: 'namespace', // parameter name is singular
+ type: 'string_options',
title: mw.msg( 'namespaces' ),
- trigger: ':',
- groups: [ {
- // Group definition (single group)
- name: 'namespace', // parameter name is singular
- type: 'string_options',
- title: mw.msg( 'namespaces' ),
- labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
- separator: ';',
- fullCoverage: true,
- filters: items
- } ]
- };
- views.invert = {
- groups: [
- {
- name: 'invertGroup',
- type: 'boolean',
- hidden: true,
- filters: [ {
- name: 'invert',
- default: '0'
- } ]
- } ]
- };
- }
- if ( tagList ) {
- views.tags = {
- title: mw.msg( 'rcfilters-view-tags' ),
- trigger: '#',
- groups: [ {
- // Group definition (single group)
- name: 'tagfilter', // Parameter name
- type: 'string_options',
- title: 'rcfilters-view-tags', // Message key
- labelPrefixKey: 'rcfilters-tag-prefix-tags',
- separator: '|',
- fullCoverage: false,
- filters: tagList
- } ]
- };
- }
-
- // Add parameter range operations
- views.range = {
- groups: [
- {
- name: 'limit',
- type: 'single_option',
- title: '', // Because it's a hidden group, this title actually appears nowhere
- hidden: true,
- allowArbitrary: true,
- // FIXME: $.isNumeric is deprecated
- validate: $.isNumeric,
- range: {
- min: 0, // The server normalizes negative numbers to 0 results
- max: 1000
- },
- sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
- default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
- sticky: true,
- filters: displayConfig.limitArray.map( function ( num ) {
- return controller._createFilterDataFromNumber( num, num );
- } )
- },
- {
- name: 'days',
- type: 'single_option',
- title: '', // Because it's a hidden group, this title actually appears nowhere
- hidden: true,
- allowArbitrary: true,
- // FIXME: $.isNumeric is deprecated
- validate: $.isNumeric,
- range: {
- min: 0,
- max: displayConfig.maxDays
- },
- sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
- numToLabelFunc: function ( i ) {
- return Number( i ) < 1 ?
- ( Number( i ) * 24 ).toFixed( 2 ) :
- Number( i );
- },
- default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
- sticky: true,
- filters: [
- // Hours (1, 2, 6, 12)
- 0.04166, 0.0833, 0.25, 0.5
- // Days
- ].concat( displayConfig.daysArray )
- .map( function ( num ) {
- return controller._createFilterDataFromNumber(
- num,
- // Convert fractions of days to number of hours for the labels
- num < 1 ? Math.round( num * 24 ) : num
- );
- } )
- }
- ]
+ labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+ separator: ';',
+ fullCoverage: true,
+ filters: items
+ } ]
};
-
- views.display = {
+ views.invert = {
groups: [
{
- name: 'display',
+ name: 'invertGroup',
type: 'boolean',
- title: '', // Because it's a hidden group, this title actually appears nowhere
hidden: true,
- sticky: true,
- filters: [
- {
- name: 'enhanced',
- default: String( mw.user.options.get( 'usenewrc', 0 ) )
- }
- ]
- }
- ]
+ filters: [ {
+ name: 'invert',
+ default: '0'
+ } ]
+ } ]
};
-
- // Before we do anything, we need to see if we require additional items in the
- // groups that have 'AllowArbitrary'. For the moment, those are only single_option
- // groups; if we ever expand it, this might need further generalization:
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( views, function ( viewName, viewData ) {
- viewData.groups.forEach( function ( groupData ) {
- var extraValues = [];
- if ( groupData.allowArbitrary ) {
- // If the value in the URI isn't in the group, add it
- if ( uri.query[ groupData.name ] !== undefined ) {
- extraValues.push( uri.query[ groupData.name ] );
- }
- // If the default value isn't in the group, add it
- if ( groupData.default !== undefined ) {
- extraValues.push( String( groupData.default ) );
- }
- controller.addNumberValuesToGroup( groupData, extraValues );
- }
- } );
- } );
-
- // Initialize the model
- this.filtersModel.initializeFilters( filterStructure, views );
-
- this.uriProcessor = new UriProcessor(
- this.filtersModel,
- { normalizeTarget: this.normalizeTarget }
- );
-
- if ( !mw.user.isAnon() ) {
- try {
- parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
- } catch ( err ) {
- parsedSavedQueries = {};
- }
-
- // Initialize saved queries
- this.savedQueriesModel.initialize( parsedSavedQueries );
- if ( this.savedQueriesModel.isConverted() ) {
- // Since we know we converted, we're going to re-save
- // the queries so they are now migrated to the new format
- this._saveSavedQueries();
- }
- }
-
- if ( defaultSavedQueryExists ) {
- // This came from the server, meaning that we have a default
- // saved query, but the server could not load it, probably because
- // it was pre-conversion to the new format.
- // We need to load this query again
- this.applySavedQuery( this.savedQueriesModel.getDefault() );
- } else {
- // There are either recognized parameters in the URL
- // or there are none, but there is also no default
- // saved query (so defaults are from the backend)
- // We want to update the state but not fetch results
- // again
- this.updateStateFromUrl( false );
-
- pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
-
- // Update the changes list with the existing data
- // so it gets processed
- this.changesListModel.update(
- pieces.changes,
- pieces.fieldset,
- pieces.noResultsDetails,
- true // We're using existing DOM elements
- );
- }
-
- this.initialized = true;
- this.switchView( 'default' );
-
- if ( this.pollingRate ) {
- this._scheduleLiveUpdate();
- }
- };
-
- /**
- * Check if the controller has finished initializing.
- * @return {boolean} Controller is initialized
- */
- Controller.prototype.isInitialized = function () {
- return this.initialized;
- };
-
- /**
- * Extracts information from the changes list DOM
- *
- * @param {jQuery} $root Root DOM to find children from
- * @param {boolean} [statusCode] Server response status code
- * @return {Object} Information about changes list
- * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
- * (either normally or as an error)
- * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
- * 'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
- * @return {jQuery} return.fieldset Fieldset
- */
- Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
- var info,
- $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
- areResults = !!$changesListContents.length,
- checkForLogout = !areResults && statusCode === 200;
-
- // We check if user logged out on different tab/browser or the session has expired.
- // 205 status code returned from the server, which indicates that we need to reload the page
- // is not usable on WL page, because we get redirected to login page, which gives 200 OK
- // status code (if everything else goes well).
- // Bug: T177717
- if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
- location.reload( false );
- return;
- }
-
- info = {
- changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
- fieldset: $root.find( 'fieldset.cloptions' ).first()
+ }
+ if ( tagList ) {
+ views.tags = {
+ title: mw.msg( 'rcfilters-view-tags' ),
+ trigger: '#',
+ groups: [ {
+ // Group definition (single group)
+ name: 'tagfilter', // Parameter name
+ type: 'string_options',
+ title: 'rcfilters-view-tags', // Message key
+ labelPrefixKey: 'rcfilters-tag-prefix-tags',
+ separator: '|',
+ fullCoverage: false,
+ filters: tagList
+ } ]
};
+ }
- if ( !areResults ) {
- if ( $root.find( '.mw-changeslist-timeout' ).length ) {
- info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
- } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
- info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
- } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
- info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
- } else {
- info.noResultsDetails = 'NO_RESULTS_NORMAL';
+ // Add parameter range operations
+ views.range = {
+ groups: [
+ {
+ name: 'limit',
+ type: 'single_option',
+ title: '', // Because it's a hidden group, this title actually appears nowhere
+ hidden: true,
+ allowArbitrary: true,
+ // FIXME: $.isNumeric is deprecated
+ validate: $.isNumeric,
+ range: {
+ min: 0, // The server normalizes negative numbers to 0 results
+ max: 1000
+ },
+ sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+ default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
+ sticky: true,
+ filters: displayConfig.limitArray.map( function ( num ) {
+ return controller._createFilterDataFromNumber( num, num );
+ } )
+ },
+ {
+ name: 'days',
+ type: 'single_option',
+ title: '', // Because it's a hidden group, this title actually appears nowhere
+ hidden: true,
+ allowArbitrary: true,
+ // FIXME: $.isNumeric is deprecated
+ validate: $.isNumeric,
+ range: {
+ min: 0,
+ max: displayConfig.maxDays
+ },
+ sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+ numToLabelFunc: function ( i ) {
+ return Number( i ) < 1 ?
+ ( Number( i ) * 24 ).toFixed( 2 ) :
+ Number( i );
+ },
+ default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
+ sticky: true,
+ filters: [
+ // Hours (1, 2, 6, 12)
+ 0.04166, 0.0833, 0.25, 0.5
+ // Days
+ ].concat( displayConfig.daysArray )
+ .map( function ( num ) {
+ return controller._createFilterDataFromNumber(
+ num,
+ // Convert fractions of days to number of hours for the labels
+ num < 1 ? Math.round( num * 24 ) : num
+ );
+ } )
}
- }
-
- return info;
+ ]
};
- /**
- * Create filter data from a number, for the filters that are numerical value
- *
- * @param {number} num Number
- * @param {number} numForDisplay Number for the label
- * @return {Object} Filter data
- */
- Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
- return {
- name: String( num ),
- label: mw.language.convertNumber( numForDisplay )
- };
+ views.display = {
+ groups: [
+ {
+ name: 'display',
+ type: 'boolean',
+ title: '', // Because it's a hidden group, this title actually appears nowhere
+ hidden: true,
+ sticky: true,
+ filters: [
+ {
+ name: 'enhanced',
+ default: String( mw.user.options.get( 'usenewrc', 0 ) )
+ }
+ ]
+ }
+ ]
};
- /**
- * Add an arbitrary values to groups that allow arbitrary values
- *
- * @param {Object} groupData Group data
- * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
- */
- Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
- var controller = this,
- normalizeWithinRange = function ( range, val ) {
- if ( val < range.min ) {
- return range.min; // Min
- } else if ( val >= range.max ) {
- return range.max; // Max
+ // Before we do anything, we need to see if we require additional items in the
+ // groups that have 'AllowArbitrary'. For the moment, those are only single_option
+ // groups; if we ever expand it, this might need further generalization:
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( views, function ( viewName, viewData ) {
+ viewData.groups.forEach( function ( groupData ) {
+ var extraValues = [];
+ if ( groupData.allowArbitrary ) {
+ // If the value in the URI isn't in the group, add it
+ if ( uri.query[ groupData.name ] !== undefined ) {
+ extraValues.push( uri.query[ groupData.name ] );
}
- return val;
- };
-
- arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
-
- // Normalize the arbitrary values and the default value for a range
- if ( groupData.range ) {
- arbitraryValues = arbitraryValues.map( function ( val ) {
- return normalizeWithinRange( groupData.range, val );
- } );
-
- // Normalize the default, since that's user defined
- if ( groupData.default !== undefined ) {
- groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
- }
- }
-
- // This is only true for single_option group
- // We assume these are the only groups that will allow for
- // arbitrary, since it doesn't make any sense for the other
- // groups.
- arbitraryValues.forEach( function ( val ) {
- if (
- // If the group allows for arbitrary data
- groupData.allowArbitrary &&
- // and it is single_option (or string_options, but we
- // don't have cases of those yet, nor do we plan to)
- groupData.type === 'single_option' &&
- // and, if there is a validate method and it passes on
- // the data
- ( !groupData.validate || groupData.validate( val ) ) &&
- // but if that value isn't already in the definition
- groupData.filters
- .map( function ( filterData ) {
- return String( filterData.name );
- } )
- .indexOf( String( val ) ) === -1
- ) {
- // Add the filter information
- groupData.filters.push( controller._createFilterDataFromNumber(
- val,
- groupData.numToLabelFunc ?
- groupData.numToLabelFunc( val ) :
- val
- ) );
-
- // If there's a sort function set up, re-sort the values
- if ( groupData.sortFunc ) {
- groupData.filters.sort( groupData.sortFunc );
+ // If the default value isn't in the group, add it
+ if ( groupData.default !== undefined ) {
+ extraValues.push( String( groupData.default ) );
}
+ controller.addNumberValuesToGroup( groupData, extraValues );
}
} );
- };
+ } );
- /**
- * Reset to default filters
- */
- Controller.prototype.resetToDefaults = function () {
- var params = this._getDefaultParams();
- if ( this.applyParamChange( params ) ) {
- // Only update the changes list if there was a change to actual filters
- this.updateChangesList();
- } else {
- this.uriProcessor.updateURL( params );
- }
- };
-
- /**
- * Check whether the default values of the filters are all false.
- *
- * @return {boolean} Defaults are all false
- */
- Controller.prototype.areDefaultsEmpty = function () {
- return $.isEmptyObject( this._getDefaultParams() );
- };
+ // Initialize the model
+ this.filtersModel.initializeFilters( filterStructure, views );
- /**
- * Empty all selected filters
- */
- Controller.prototype.emptyFilters = function () {
- var highlightedFilterNames = this.filtersModel.getHighlightedItems()
- .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+ this.uriProcessor = new UriProcessor(
+ this.filtersModel,
+ { normalizeTarget: this.normalizeTarget }
+ );
- if ( this.applyParamChange( {} ) ) {
- // Only update the changes list if there was a change to actual filters
- this.updateChangesList();
- } else {
- this.uriProcessor.updateURL();
+ if ( !mw.user.isAnon() ) {
+ try {
+ parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
+ } catch ( err ) {
+ parsedSavedQueries = {};
}
- if ( highlightedFilterNames ) {
- this._trackHighlight( 'clearAll', highlightedFilterNames );
+ // Initialize saved queries
+ this.savedQueriesModel.initialize( parsedSavedQueries );
+ if ( this.savedQueriesModel.isConverted() ) {
+ // Since we know we converted, we're going to re-save
+ // the queries so they are now migrated to the new format
+ this._saveSavedQueries();
}
+ }
+
+ if ( defaultSavedQueryExists ) {
+ // This came from the server, meaning that we have a default
+ // saved query, but the server could not load it, probably because
+ // it was pre-conversion to the new format.
+ // We need to load this query again
+ this.applySavedQuery( this.savedQueriesModel.getDefault() );
+ } else {
+ // There are either recognized parameters in the URL
+ // or there are none, but there is also no default
+ // saved query (so defaults are from the backend)
+ // We want to update the state but not fetch results
+ // again
+ this.updateStateFromUrl( false );
+
+ pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
+
+ // Update the changes list with the existing data
+ // so it gets processed
+ this.changesListModel.update(
+ pieces.changes,
+ pieces.fieldset,
+ pieces.noResultsDetails,
+ true // We're using existing DOM elements
+ );
+ }
+
+ this.initialized = true;
+ this.switchView( 'default' );
+
+ if ( this.pollingRate ) {
+ this._scheduleLiveUpdate();
+ }
+};
+
+/**
+ * Check if the controller has finished initializing.
+ * @return {boolean} Controller is initialized
+ */
+Controller.prototype.isInitialized = function () {
+ return this.initialized;
+};
+
+/**
+ * Extracts information from the changes list DOM
+ *
+ * @param {jQuery} $root Root DOM to find children from
+ * @param {boolean} [statusCode] Server response status code
+ * @return {Object} Information about changes list
+ * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
+ * (either normally or as an error)
+ * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
+ * 'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
+ * @return {jQuery} return.fieldset Fieldset
+ */
+Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
+ var info,
+ $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
+ areResults = !!$changesListContents.length,
+ checkForLogout = !areResults && statusCode === 200;
+
+ // We check if user logged out on different tab/browser or the session has expired.
+ // 205 status code returned from the server, which indicates that we need to reload the page
+ // is not usable on WL page, because we get redirected to login page, which gives 200 OK
+ // status code (if everything else goes well).
+ // Bug: T177717
+ if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
+ location.reload( false );
+ return;
+ }
+
+ info = {
+ changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
+ fieldset: $root.find( 'fieldset.cloptions' ).first()
};
- /**
- * Update the selected state of a filter
- *
- * @param {string} filterName Filter name
- * @param {boolean} [isSelected] Filter selected state
- */
- Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
- var filterItem = this.filtersModel.getItemByName( filterName );
-
- if ( !filterItem ) {
- // If no filter was found, break
- return;
- }
-
- isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
-
- if ( filterItem.isSelected() !== isSelected ) {
- this.filtersModel.toggleFilterSelected( filterName, isSelected );
-
- this.updateChangesList();
-
- // Check filter interactions
- this.filtersModel.reassessFilterInteractions( filterItem );
+ if ( !areResults ) {
+ if ( $root.find( '.mw-changeslist-timeout' ).length ) {
+ info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
+ } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
+ info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
+ } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
+ info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
+ } else {
+ info.noResultsDetails = 'NO_RESULTS_NORMAL';
}
+ }
+
+ return info;
+};
+
+/**
+ * Create filter data from a number, for the filters that are numerical value
+ *
+ * @param {number} num Number
+ * @param {number} numForDisplay Number for the label
+ * @return {Object} Filter data
+ */
+Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
+ return {
+ name: String( num ),
+ label: mw.language.convertNumber( numForDisplay )
};
-
- /**
- * Clear both highlight and selection of a filter
- *
- * @param {string} filterName Name of the filter item
- */
- Controller.prototype.clearFilter = function ( filterName ) {
- var filterItem = this.filtersModel.getItemByName( filterName ),
- isHighlighted = filterItem.isHighlighted(),
- isSelected = filterItem.isSelected();
-
- if ( isSelected || isHighlighted ) {
- this.filtersModel.clearHighlightColor( filterName );
- this.filtersModel.toggleFilterSelected( filterName, false );
-
- if ( isSelected ) {
- // Only update the changes list if the filter changed
- // its selection state. If it only changed its highlight
- // then don't reload
- this.updateChangesList();
+};
+
+/**
+ * Add an arbitrary values to groups that allow arbitrary values
+ *
+ * @param {Object} groupData Group data
+ * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
+ */
+Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
+ var controller = this,
+ normalizeWithinRange = function ( range, val ) {
+ if ( val < range.min ) {
+ return range.min; // Min
+ } else if ( val >= range.max ) {
+ return range.max; // Max
}
+ return val;
+ };
- this.filtersModel.reassessFilterInteractions( filterItem );
-
- // Log filter grouping
- this.trackFilterGroupings( 'removefilter' );
- }
-
- if ( isHighlighted ) {
- this._trackHighlight( 'clear', filterName );
- }
- };
+ arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
- /**
- * Toggle the highlight feature on and off
- */
- Controller.prototype.toggleHighlight = function () {
- this.filtersModel.toggleHighlight();
- this.uriProcessor.updateURL();
+ // Normalize the arbitrary values and the default value for a range
+ if ( groupData.range ) {
+ arbitraryValues = arbitraryValues.map( function ( val ) {
+ return normalizeWithinRange( groupData.range, val );
+ } );
- if ( this.filtersModel.isHighlightEnabled() ) {
- mw.hook( 'RcFilters.highlight.enable' ).fire();
+ // Normalize the default, since that's user defined
+ if ( groupData.default !== undefined ) {
+ groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
}
- };
+ }
- /**
- * Toggle the namespaces inverted feature on and off
- */
- Controller.prototype.toggleInvertedNamespaces = function () {
- this.filtersModel.toggleInvertedNamespaces();
+ // This is only true for single_option group
+ // We assume these are the only groups that will allow for
+ // arbitrary, since it doesn't make any sense for the other
+ // groups.
+ arbitraryValues.forEach( function ( val ) {
if (
- this.filtersModel.getFiltersByView( 'namespaces' ).filter(
- function ( filterItem ) { return filterItem.isSelected(); }
- ).length
+ // If the group allows for arbitrary data
+ groupData.allowArbitrary &&
+ // and it is single_option (or string_options, but we
+ // don't have cases of those yet, nor do we plan to)
+ groupData.type === 'single_option' &&
+ // and, if there is a validate method and it passes on
+ // the data
+ ( !groupData.validate || groupData.validate( val ) ) &&
+ // but if that value isn't already in the definition
+ groupData.filters
+ .map( function ( filterData ) {
+ return String( filterData.name );
+ } )
+ .indexOf( String( val ) ) === -1
) {
- // Only re-fetch results if there are namespace items that are actually selected
- this.updateChangesList();
- } else {
- this.uriProcessor.updateURL();
- }
- };
-
- /**
- * Set the value of the 'showlinkedto' parameter
- * @param {boolean} value
- */
- Controller.prototype.setShowLinkedTo = function ( value ) {
- var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
- showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
-
- this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
- this.uriProcessor.updateURL();
- // reload the results only when target is set
- if ( targetItem.getValue() ) {
- this.updateChangesList();
+ // Add the filter information
+ groupData.filters.push( controller._createFilterDataFromNumber(
+ val,
+ groupData.numToLabelFunc ?
+ groupData.numToLabelFunc( val ) :
+ val
+ ) );
+
+ // If there's a sort function set up, re-sort the values
+ if ( groupData.sortFunc ) {
+ groupData.filters.sort( groupData.sortFunc );
+ }
}
- };
-
- /**
- * Set the target page
- * @param {string} page
- */
- Controller.prototype.setTargetPage = function ( page ) {
- var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
- targetItem.setValue( page );
- this.uriProcessor.updateURL();
+ } );
+};
+
+/**
+ * Reset to default filters
+ */
+Controller.prototype.resetToDefaults = function () {
+ var params = this._getDefaultParams();
+ if ( this.applyParamChange( params ) ) {
+ // Only update the changes list if there was a change to actual filters
this.updateChangesList();
- };
-
- /**
- * Set the highlight color for a filter item
- *
- * @param {string} filterName Name of the filter item
- * @param {string} color Selected color
- */
- Controller.prototype.setHighlightColor = function ( filterName, color ) {
- this.filtersModel.setHighlightColor( filterName, color );
- this.uriProcessor.updateURL();
- this._trackHighlight( 'set', { name: filterName, color: color } );
- };
-
- /**
- * Clear highlight for a filter item
- *
- * @param {string} filterName Name of the filter item
- */
- Controller.prototype.clearHighlightColor = function ( filterName ) {
- this.filtersModel.clearHighlightColor( filterName );
+ } else {
+ this.uriProcessor.updateURL( params );
+ }
+};
+
+/**
+ * Check whether the default values of the filters are all false.
+ *
+ * @return {boolean} Defaults are all false
+ */
+Controller.prototype.areDefaultsEmpty = function () {
+ return $.isEmptyObject( this._getDefaultParams() );
+};
+
+/**
+ * Empty all selected filters
+ */
+Controller.prototype.emptyFilters = function () {
+ var highlightedFilterNames = this.filtersModel.getHighlightedItems()
+ .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+
+ if ( this.applyParamChange( {} ) ) {
+ // Only update the changes list if there was a change to actual filters
+ this.updateChangesList();
+ } else {
this.uriProcessor.updateURL();
- this._trackHighlight( 'clear', filterName );
- };
+ }
- /**
- * Enable or disable live updates.
- * @param {boolean} enable True to enable, false to disable
- */
- Controller.prototype.toggleLiveUpdate = function ( enable ) {
- this.changesListModel.toggleLiveUpdate( enable );
- if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
- this.updateChangesList( null, this.LIVE_UPDATE );
- }
- };
+ if ( highlightedFilterNames ) {
+ this._trackHighlight( 'clearAll', highlightedFilterNames );
+ }
+};
- /**
- * Set a timeout for the next live update.
- * @private
- */
- Controller.prototype._scheduleLiveUpdate = function () {
- setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
- };
+/**
+ * Update the selected state of a filter
+ *
+ * @param {string} filterName Filter name
+ * @param {boolean} [isSelected] Filter selected state
+ */
+Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
+ var filterItem = this.filtersModel.getItemByName( filterName );
- /**
- * Perform a live update.
- * @private
- */
- Controller.prototype._doLiveUpdate = function () {
- if ( !this._shouldCheckForNewChanges() ) {
- // skip this turn and check back later
- this._scheduleLiveUpdate();
- return;
- }
+ if ( !filterItem ) {
+ // If no filter was found, break
+ return;
+ }
- this._checkForNewChanges()
- .then( function ( statusCode ) {
- // no result is 204 with the 'peek' param
- // logged out is 205
- var newChanges = statusCode === 200;
+ isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
- if ( !this._shouldCheckForNewChanges() ) {
- // by the time the response is received,
- // it may not be appropriate anymore
- return;
- }
+ if ( filterItem.isSelected() !== isSelected ) {
+ this.filtersModel.toggleFilterSelected( filterName, isSelected );
- // 205 is the status code returned from server when user's logged in/out
- // status is not matching while fetching live update changes.
- // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
- // Bug: T177717
- if ( statusCode === 205 ) {
- location.reload( false );
- return;
- }
-
- if ( newChanges ) {
- if ( this.changesListModel.getLiveUpdate() ) {
- return this.updateChangesList( null, this.LIVE_UPDATE );
- } else {
- this.changesListModel.setNewChangesExist( true );
- }
- }
- }.bind( this ) )
- .always( this._scheduleLiveUpdate.bind( this ) );
- };
-
- /**
- * @return {boolean} It's appropriate to check for new changes now
- * @private
- */
- Controller.prototype._shouldCheckForNewChanges = function () {
- return !document.hidden &&
- !this.filtersModel.hasConflict() &&
- !this.changesListModel.getNewChangesExist() &&
- !this.updatingChangesList &&
- this.changesListModel.getNextFrom();
- };
-
- /**
- * Check if new changes, newer than those currently shown, are available
- *
- * @return {jQuery.Promise} Promise object that resolves with a bool
- * specifying if there are new changes or not
- *
- * @private
- */
- Controller.prototype._checkForNewChanges = function () {
- var params = {
- limit: 1,
- peek: 1, // bypasses ChangesList specific UI
- from: this.changesListModel.getNextFrom(),
- isAnon: mw.user.isAnon()
- };
- return this._queryChangesList( 'liveUpdate', params ).then(
- function ( data ) {
- return data.status;
- }
- );
- };
-
- /**
- * Show the new changes
- *
- * @return {jQuery.Promise} Promise object that resolves after
- * fetching and showing the new changes
- */
- Controller.prototype.showNewChanges = function () {
- return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
- };
-
- /**
- * Save the current model state as a saved query
- *
- * @param {string} [label] Label of the saved query
- * @param {boolean} [setAsDefault=false] This query should be set as the default
- */
- Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
- // Add item
- this.savedQueriesModel.addNewQuery(
- label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
- this.filtersModel.getCurrentParameterState( true ),
- setAsDefault
- );
-
- // Save item
- this._saveSavedQueries();
- };
-
- /**
- * Remove a saved query
- *
- * @param {string} queryID Query id
- */
- Controller.prototype.removeSavedQuery = function ( queryID ) {
- this.savedQueriesModel.removeQuery( queryID );
-
- this._saveSavedQueries();
- };
-
- /**
- * Rename a saved query
- *
- * @param {string} queryID Query id
- * @param {string} newLabel New label for the query
- */
- Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
- var queryItem = this.savedQueriesModel.getItemByID( queryID );
-
- if ( queryItem ) {
- queryItem.updateLabel( newLabel );
- }
- this._saveSavedQueries();
- };
-
- /**
- * Set a saved query as default
- *
- * @param {string} queryID Query Id. If null is given, default
- * query is reset.
- */
- Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
- this.savedQueriesModel.setDefault( queryID );
- this._saveSavedQueries();
- };
-
- /**
- * Load a saved query
- *
- * @param {string} queryID Query id
- */
- Controller.prototype.applySavedQuery = function ( queryID ) {
- var currentMatchingQuery,
- params = this.savedQueriesModel.getItemParams( queryID );
-
- currentMatchingQuery = this.findQueryMatchingCurrentState();
+ this.updateChangesList();
- if (
- currentMatchingQuery &&
- currentMatchingQuery.getID() === queryID
- ) {
- // If the query we want to load is the one that is already
- // loaded, don't reload it
- return;
- }
+ // Check filter interactions
+ this.filtersModel.reassessFilterInteractions( filterItem );
+ }
+};
+
+/**
+ * Clear both highlight and selection of a filter
+ *
+ * @param {string} filterName Name of the filter item
+ */
+Controller.prototype.clearFilter = function ( filterName ) {
+ var filterItem = this.filtersModel.getItemByName( filterName ),
+ isHighlighted = filterItem.isHighlighted(),
+ isSelected = filterItem.isSelected();
+
+ if ( isSelected || isHighlighted ) {
+ this.filtersModel.clearHighlightColor( filterName );
+ this.filtersModel.toggleFilterSelected( filterName, false );
- if ( this.applyParamChange( params ) ) {
- // Update changes list only if there was a difference in filter selection
+ if ( isSelected ) {
+ // Only update the changes list if the filter changed
+ // its selection state. If it only changed its highlight
+ // then don't reload
this.updateChangesList();
- } else {
- this.uriProcessor.updateURL( params );
- }
-
- // Log filter grouping
- this.trackFilterGroupings( 'savedfilters' );
- };
-
- /**
- * Check whether the current filter and highlight state exists
- * in the saved queries model.
- *
- * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
- */
- Controller.prototype.findQueryMatchingCurrentState = function () {
- return this.savedQueriesModel.findMatchingQuery(
- this.filtersModel.getCurrentParameterState( true )
- );
- };
-
- /**
- * Save the current state of the saved queries model with all
- * query item representation in the user settings.
- */
- Controller.prototype._saveSavedQueries = function () {
- var stringified, oldPrefValue,
- backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
- state = this.savedQueriesModel.getState();
-
- // Stringify state
- stringified = JSON.stringify( state );
-
- if ( byteLength( stringified ) > 65535 ) {
- // Sanity check, since the preference can only hold that.
- return;
}
- if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
- // The queries were converted from the previous version
- // Keep the old string in the [prefname]-versionbackup
- oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+ this.filtersModel.reassessFilterInteractions( filterItem );
- // Save the old preference in the backup preference
- new mw.Api().saveOption( backupPrefName, oldPrefValue );
- // Update the preference for this session
- mw.user.options.set( backupPrefName, oldPrefValue );
- }
-
- // Save the preference
- new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
- // Update the preference for this session
- mw.user.options.set( this.savedQueriesPreferenceName, stringified );
-
- // Tag as already saved so we don't do this again
- this.wereSavedQueriesSaved = true;
- };
-
- /**
- * Update sticky preferences with current model state
- */
- Controller.prototype.updateStickyPreferences = function () {
- // Update default sticky values with selected, whether they came from
- // the initial defaults or from the URL value that is being normalized
- this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
- this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
-
- // TODO: Make these automatic by having the model go over sticky
- // items and update their default values automatically
- };
-
- /**
- * Update the limit default value
- *
- * @param {number} newValue New value
- */
- Controller.prototype.updateLimitDefault = function ( newValue ) {
- this.updateNumericPreference( this.limitPreferenceName, newValue );
- };
-
- /**
- * Update the days default value
- *
- * @param {number} newValue New value
- */
- Controller.prototype.updateDaysDefault = function ( newValue ) {
- this.updateNumericPreference( this.daysPreferenceName, newValue );
- };
-
- /**
- * Update the group by page default value
- *
- * @param {boolean} newValue New value
- */
- Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
- this.updateNumericPreference( 'usenewrc', Number( newValue ) );
- };
-
- /**
- * Update the collapsed state value
- *
- * @param {boolean} isCollapsed Filter area is collapsed
- */
- Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
- this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
- };
-
- /**
- * Update a numeric preference with a new value
- *
- * @param {string} prefName Preference name
- * @param {number|string} newValue New value
- */
- Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
- // FIXME: $.isNumeric is deprecated
- // eslint-disable-next-line no-jquery/no-is-numeric
- if ( !$.isNumeric( newValue ) ) {
- return;
- }
-
- newValue = Number( newValue );
-
- if ( mw.user.options.get( prefName ) !== newValue ) {
- // Save the preference
- new mw.Api().saveOption( prefName, newValue );
- // Update the preference for this session
- mw.user.options.set( prefName, newValue );
- }
- };
+ // Log filter grouping
+ this.trackFilterGroupings( 'removefilter' );
+ }
- /**
- * Synchronize the URL with the current state of the filters
- * without adding an history entry.
- */
- Controller.prototype.replaceUrl = function () {
+ if ( isHighlighted ) {
+ this._trackHighlight( 'clear', filterName );
+ }
+};
+
+/**
+ * Toggle the highlight feature on and off
+ */
+Controller.prototype.toggleHighlight = function () {
+ this.filtersModel.toggleHighlight();
+ this.uriProcessor.updateURL();
+
+ if ( this.filtersModel.isHighlightEnabled() ) {
+ mw.hook( 'RcFilters.highlight.enable' ).fire();
+ }
+};
+
+/**
+ * Toggle the namespaces inverted feature on and off
+ */
+Controller.prototype.toggleInvertedNamespaces = function () {
+ this.filtersModel.toggleInvertedNamespaces();
+ if (
+ this.filtersModel.getFiltersByView( 'namespaces' ).filter(
+ function ( filterItem ) { return filterItem.isSelected(); }
+ ).length
+ ) {
+ // Only re-fetch results if there are namespace items that are actually selected
+ this.updateChangesList();
+ } else {
this.uriProcessor.updateURL();
- };
-
- /**
- * Update filter state (selection and highlighting) based
- * on current URL values.
- *
- * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
- * list based on the updated model.
- */
- Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
- fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
-
- this.uriProcessor.updateModelBasedOnQuery();
-
- // Update the sticky preferences, in case we received a value
- // from the URL
- this.updateStickyPreferences();
+ }
+};
+
+/**
+ * Set the value of the 'showlinkedto' parameter
+ * @param {boolean} value
+ */
+Controller.prototype.setShowLinkedTo = function ( value ) {
+ var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
+ showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
+
+ this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
+ this.uriProcessor.updateURL();
+ // reload the results only when target is set
+ if ( targetItem.getValue() ) {
+ this.updateChangesList();
+ }
+};
+
+/**
+ * Set the target page
+ * @param {string} page
+ */
+Controller.prototype.setTargetPage = function ( page ) {
+ var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
+ targetItem.setValue( page );
+ this.uriProcessor.updateURL();
+ this.updateChangesList();
+};
+
+/**
+ * Set the highlight color for a filter item
+ *
+ * @param {string} filterName Name of the filter item
+ * @param {string} color Selected color
+ */
+Controller.prototype.setHighlightColor = function ( filterName, color ) {
+ this.filtersModel.setHighlightColor( filterName, color );
+ this.uriProcessor.updateURL();
+ this._trackHighlight( 'set', { name: filterName, color: color } );
+};
+
+/**
+ * Clear highlight for a filter item
+ *
+ * @param {string} filterName Name of the filter item
+ */
+Controller.prototype.clearHighlightColor = function ( filterName ) {
+ this.filtersModel.clearHighlightColor( filterName );
+ this.uriProcessor.updateURL();
+ this._trackHighlight( 'clear', filterName );
+};
+
+/**
+ * Enable or disable live updates.
+ * @param {boolean} enable True to enable, false to disable
+ */
+Controller.prototype.toggleLiveUpdate = function ( enable ) {
+ this.changesListModel.toggleLiveUpdate( enable );
+ if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
+ this.updateChangesList( null, this.LIVE_UPDATE );
+ }
+};
+
+/**
+ * Set a timeout for the next live update.
+ * @private
+ */
+Controller.prototype._scheduleLiveUpdate = function () {
+ setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
+};
+
+/**
+ * Perform a live update.
+ * @private
+ */
+Controller.prototype._doLiveUpdate = function () {
+ if ( !this._shouldCheckForNewChanges() ) {
+ // skip this turn and check back later
+ this._scheduleLiveUpdate();
+ return;
+ }
+
+ this._checkForNewChanges()
+ .then( function ( statusCode ) {
+ // no result is 204 with the 'peek' param
+ // logged out is 205
+ var newChanges = statusCode === 200;
+
+ if ( !this._shouldCheckForNewChanges() ) {
+ // by the time the response is received,
+ // it may not be appropriate anymore
+ return;
+ }
- // Only update and fetch new results if it is requested
- if ( fetchChangesList ) {
- this.updateChangesList();
- }
- };
+ // 205 is the status code returned from server when user's logged in/out
+ // status is not matching while fetching live update changes.
+ // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
+ // Bug: T177717
+ if ( statusCode === 205 ) {
+ location.reload( false );
+ return;
+ }
- /**
- * Update the list of changes and notify the model
- *
- * @param {Object} [params] Extra parameters to add to the API call
- * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
- * @return {jQuery.Promise} Promise that is resolved when the update is complete
- */
- Controller.prototype.updateChangesList = function ( params, updateMode ) {
- updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
-
- if ( updateMode === this.FILTER_CHANGE ) {
- this.uriProcessor.updateURL( params );
- }
- if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
- this.changesListModel.invalidate();
- }
- this.changesListModel.setNewChangesExist( false );
- this.updatingChangesList = true;
- return this._fetchChangesList()
- .then(
- // Success
- function ( pieces ) {
- var $changesListContent = pieces.changes,
- $fieldset = pieces.fieldset;
- this.changesListModel.update(
- $changesListContent,
- $fieldset,
- pieces.noResultsDetails,
- false,
- // separator between old and new changes
- updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
- );
- }.bind( this )
- // Do nothing for failure
- )
- .always( function () {
- this.updatingChangesList = false;
- }.bind( this ) );
+ if ( newChanges ) {
+ if ( this.changesListModel.getLiveUpdate() ) {
+ return this.updateChangesList( null, this.LIVE_UPDATE );
+ } else {
+ this.changesListModel.setNewChangesExist( true );
+ }
+ }
+ }.bind( this ) )
+ .always( this._scheduleLiveUpdate.bind( this ) );
+};
+
+/**
+ * @return {boolean} It's appropriate to check for new changes now
+ * @private
+ */
+Controller.prototype._shouldCheckForNewChanges = function () {
+ return !document.hidden &&
+ !this.filtersModel.hasConflict() &&
+ !this.changesListModel.getNewChangesExist() &&
+ !this.updatingChangesList &&
+ this.changesListModel.getNextFrom();
+};
+
+/**
+ * Check if new changes, newer than those currently shown, are available
+ *
+ * @return {jQuery.Promise} Promise object that resolves with a bool
+ * specifying if there are new changes or not
+ *
+ * @private
+ */
+Controller.prototype._checkForNewChanges = function () {
+ var params = {
+ limit: 1,
+ peek: 1, // bypasses ChangesList specific UI
+ from: this.changesListModel.getNextFrom(),
+ isAnon: mw.user.isAnon()
};
-
- /**
- * Get an object representing the default parameter state, whether
- * it is from the model defaults or from the saved queries.
- *
- * @return {Object} Default parameters
- */
- Controller.prototype._getDefaultParams = function () {
- if ( this.savedQueriesModel.getDefault() ) {
- return this.savedQueriesModel.getDefaultParams();
- } else {
- return this.filtersModel.getDefaultParams();
+ return this._queryChangesList( 'liveUpdate', params ).then(
+ function ( data ) {
+ return data.status;
}
- };
+ );
+};
+
+/**
+ * Show the new changes
+ *
+ * @return {jQuery.Promise} Promise object that resolves after
+ * fetching and showing the new changes
+ */
+Controller.prototype.showNewChanges = function () {
+ return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
+};
+
+/**
+ * Save the current model state as a saved query
+ *
+ * @param {string} [label] Label of the saved query
+ * @param {boolean} [setAsDefault=false] This query should be set as the default
+ */
+Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
+ // Add item
+ this.savedQueriesModel.addNewQuery(
+ label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
+ this.filtersModel.getCurrentParameterState( true ),
+ setAsDefault
+ );
+
+ // Save item
+ this._saveSavedQueries();
+};
+
+/**
+ * Remove a saved query
+ *
+ * @param {string} queryID Query id
+ */
+Controller.prototype.removeSavedQuery = function ( queryID ) {
+ this.savedQueriesModel.removeQuery( queryID );
+
+ this._saveSavedQueries();
+};
+
+/**
+ * Rename a saved query
+ *
+ * @param {string} queryID Query id
+ * @param {string} newLabel New label for the query
+ */
+Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
+ var queryItem = this.savedQueriesModel.getItemByID( queryID );
+
+ if ( queryItem ) {
+ queryItem.updateLabel( newLabel );
+ }
+ this._saveSavedQueries();
+};
+
+/**
+ * Set a saved query as default
+ *
+ * @param {string} queryID Query Id. If null is given, default
+ * query is reset.
+ */
+Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
+ this.savedQueriesModel.setDefault( queryID );
+ this._saveSavedQueries();
+};
+
+/**
+ * Load a saved query
+ *
+ * @param {string} queryID Query id
+ */
+Controller.prototype.applySavedQuery = function ( queryID ) {
+ var currentMatchingQuery,
+ params = this.savedQueriesModel.getItemParams( queryID );
+
+ currentMatchingQuery = this.findQueryMatchingCurrentState();
+
+ if (
+ currentMatchingQuery &&
+ currentMatchingQuery.getID() === queryID
+ ) {
+ // If the query we want to load is the one that is already
+ // loaded, don't reload it
+ return;
+ }
+
+ if ( this.applyParamChange( params ) ) {
+ // Update changes list only if there was a difference in filter selection
+ this.updateChangesList();
+ } else {
+ this.uriProcessor.updateURL( params );
+ }
+
+ // Log filter grouping
+ this.trackFilterGroupings( 'savedfilters' );
+};
+
+/**
+ * Check whether the current filter and highlight state exists
+ * in the saved queries model.
+ *
+ * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+ */
+Controller.prototype.findQueryMatchingCurrentState = function () {
+ return this.savedQueriesModel.findMatchingQuery(
+ this.filtersModel.getCurrentParameterState( true )
+ );
+};
+
+/**
+ * Save the current state of the saved queries model with all
+ * query item representation in the user settings.
+ */
+Controller.prototype._saveSavedQueries = function () {
+ var stringified, oldPrefValue,
+ backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
+ state = this.savedQueriesModel.getState();
+
+ // Stringify state
+ stringified = JSON.stringify( state );
+
+ if ( byteLength( stringified ) > 65535 ) {
+ // Sanity check, since the preference can only hold that.
+ return;
+ }
+
+ if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
+ // The queries were converted from the previous version
+ // Keep the old string in the [prefname]-versionbackup
+ oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+
+ // Save the old preference in the backup preference
+ new mw.Api().saveOption( backupPrefName, oldPrefValue );
+ // Update the preference for this session
+ mw.user.options.set( backupPrefName, oldPrefValue );
+ }
+
+ // Save the preference
+ new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
+ // Update the preference for this session
+ mw.user.options.set( this.savedQueriesPreferenceName, stringified );
+
+ // Tag as already saved so we don't do this again
+ this.wereSavedQueriesSaved = true;
+};
+
+/**
+ * Update sticky preferences with current model state
+ */
+Controller.prototype.updateStickyPreferences = function () {
+ // Update default sticky values with selected, whether they came from
+ // the initial defaults or from the URL value that is being normalized
+ this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
+ this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
+
+ // TODO: Make these automatic by having the model go over sticky
+ // items and update their default values automatically
+};
+
+/**
+ * Update the limit default value
+ *
+ * @param {number} newValue New value
+ */
+Controller.prototype.updateLimitDefault = function ( newValue ) {
+ this.updateNumericPreference( this.limitPreferenceName, newValue );
+};
+
+/**
+ * Update the days default value
+ *
+ * @param {number} newValue New value
+ */
+Controller.prototype.updateDaysDefault = function ( newValue ) {
+ this.updateNumericPreference( this.daysPreferenceName, newValue );
+};
+
+/**
+ * Update the group by page default value
+ *
+ * @param {boolean} newValue New value
+ */
+Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
+ this.updateNumericPreference( 'usenewrc', Number( newValue ) );
+};
+
+/**
+ * Update the collapsed state value
+ *
+ * @param {boolean} isCollapsed Filter area is collapsed
+ */
+Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
+ this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
+};
+
+/**
+ * Update a numeric preference with a new value
+ *
+ * @param {string} prefName Preference name
+ * @param {number|string} newValue New value
+ */
+Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
+ // FIXME: $.isNumeric is deprecated
+ // eslint-disable-next-line no-jquery/no-is-numeric
+ if ( !$.isNumeric( newValue ) ) {
+ return;
+ }
+
+ newValue = Number( newValue );
+
+ if ( mw.user.options.get( prefName ) !== newValue ) {
+ // Save the preference
+ new mw.Api().saveOption( prefName, newValue );
+ // Update the preference for this session
+ mw.user.options.set( prefName, newValue );
+ }
+};
+
+/**
+ * Synchronize the URL with the current state of the filters
+ * without adding an history entry.
+ */
+Controller.prototype.replaceUrl = function () {
+ this.uriProcessor.updateURL();
+};
+
+/**
+ * Update filter state (selection and highlighting) based
+ * on current URL values.
+ *
+ * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
+ * list based on the updated model.
+ */
+Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
+ fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
+
+ this.uriProcessor.updateModelBasedOnQuery();
+
+ // Update the sticky preferences, in case we received a value
+ // from the URL
+ this.updateStickyPreferences();
+
+ // Only update and fetch new results if it is requested
+ if ( fetchChangesList ) {
+ this.updateChangesList();
+ }
+};
+
+/**
+ * Update the list of changes and notify the model
+ *
+ * @param {Object} [params] Extra parameters to add to the API call
+ * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
+ * @return {jQuery.Promise} Promise that is resolved when the update is complete
+ */
+Controller.prototype.updateChangesList = function ( params, updateMode ) {
+ updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
+
+ if ( updateMode === this.FILTER_CHANGE ) {
+ this.uriProcessor.updateURL( params );
+ }
+ if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
+ this.changesListModel.invalidate();
+ }
+ this.changesListModel.setNewChangesExist( false );
+ this.updatingChangesList = true;
+ return this._fetchChangesList()
+ .then(
+ // Success
+ function ( pieces ) {
+ var $changesListContent = pieces.changes,
+ $fieldset = pieces.fieldset;
+ this.changesListModel.update(
+ $changesListContent,
+ $fieldset,
+ pieces.noResultsDetails,
+ false,
+ // separator between old and new changes
+ updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
+ );
+ }.bind( this )
+ // Do nothing for failure
+ )
+ .always( function () {
+ this.updatingChangesList = false;
+ }.bind( this ) );
+};
+
+/**
+ * Get an object representing the default parameter state, whether
+ * it is from the model defaults or from the saved queries.
+ *
+ * @return {Object} Default parameters
+ */
+Controller.prototype._getDefaultParams = function () {
+ if ( this.savedQueriesModel.getDefault() ) {
+ return this.savedQueriesModel.getDefaultParams();
+ } else {
+ return this.filtersModel.getDefaultParams();
+ }
+};
+
+/**
+ * Query the list of changes from the server for the current filters
+ *
+ * @param {string} counterId Id for this request. To allow concurrent requests
+ * not to invalidate each other.
+ * @param {Object} [params={}] Parameters to add to the query
+ *
+ * @return {jQuery.Promise} Promise object resolved with { content, status }
+ */
+Controller.prototype._queryChangesList = function ( counterId, params ) {
+ var uri = this.uriProcessor.getUpdatedUri(),
+ stickyParams = this.filtersModel.getStickyParamsValues(),
+ requestId,
+ latestRequest;
+
+ params = params || {};
+ params.action = 'render'; // bypasses MW chrome
+
+ uri.extend( params );
+
+ this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
+ requestId = ++this.requestCounter[ counterId ];
+ latestRequest = function () {
+ return requestId === this.requestCounter[ counterId ];
+ }.bind( this );
+
+ // Sticky parameters override the URL params
+ // this is to make sure that whether we represent
+ // the sticky params in the URL or not (they may
+ // be normalized out) the sticky parameters are
+ // always being sent to the server with their
+ // current/default values
+ uri.extend( stickyParams );
+
+ return $.ajax( uri.toString(), { contentType: 'html' } )
+ .then(
+ function ( content, message, jqXHR ) {
+ if ( !latestRequest() ) {
+ return $.Deferred().reject();
+ }
+ return {
+ content: content,
+ status: jqXHR.status
+ };
+ },
+ // RC returns 404 when there is no results
+ function ( jqXHR ) {
+ if ( latestRequest() ) {
+ return $.Deferred().resolve(
+ {
+ content: jqXHR.responseText,
+ status: jqXHR.status
+ }
+ ).promise();
+ }
+ }
+ );
+};
+
+/**
+ * Fetch the list of changes from the server for the current filters
+ *
+ * @return {jQuery.Promise} Promise object that will resolve with the changes list
+ * and the fieldset.
+ */
+Controller.prototype._fetchChangesList = function () {
+ return this._queryChangesList( 'updateChangesList' )
+ .then(
+ function ( data ) {
+ var $parsed;
- /**
- * Query the list of changes from the server for the current filters
- *
- * @param {string} counterId Id for this request. To allow concurrent requests
- * not to invalidate each other.
- * @param {Object} [params={}] Parameters to add to the query
- *
- * @return {jQuery.Promise} Promise object resolved with { content, status }
- */
- Controller.prototype._queryChangesList = function ( counterId, params ) {
- var uri = this.uriProcessor.getUpdatedUri(),
- stickyParams = this.filtersModel.getStickyParamsValues(),
- requestId,
- latestRequest;
-
- params = params || {};
- params.action = 'render'; // bypasses MW chrome
-
- uri.extend( params );
-
- this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
- requestId = ++this.requestCounter[ counterId ];
- latestRequest = function () {
- return requestId === this.requestCounter[ counterId ];
- }.bind( this );
-
- // Sticky parameters override the URL params
- // this is to make sure that whether we represent
- // the sticky params in the URL or not (they may
- // be normalized out) the sticky parameters are
- // always being sent to the server with their
- // current/default values
- uri.extend( stickyParams );
-
- return $.ajax( uri.toString(), { contentType: 'html' } )
- .then(
- function ( content, message, jqXHR ) {
- if ( !latestRequest() ) {
- return $.Deferred().reject();
- }
+ // Status code 0 is not HTTP status code,
+ // but is valid value of XMLHttpRequest status.
+ // It is used for variety of network errors, for example
+ // when an AJAX call was cancelled before getting the response
+ if ( data && data.status === 0 ) {
return {
- content: content,
- status: jqXHR.status
+ changes: 'NO_RESULTS',
+ // We need empty result set, to avoid exceptions because of undefined value
+ fieldset: $( [] ),
+ noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
};
- },
- // RC returns 404 when there is no results
- function ( jqXHR ) {
- if ( latestRequest() ) {
- return $.Deferred().resolve(
- {
- content: jqXHR.responseText,
- status: jqXHR.status
- }
- ).promise();
- }
}
- );
- };
-
- /**
- * Fetch the list of changes from the server for the current filters
- *
- * @return {jQuery.Promise} Promise object that will resolve with the changes list
- * and the fieldset.
- */
- Controller.prototype._fetchChangesList = function () {
- return this._queryChangesList( 'updateChangesList' )
- .then(
- function ( data ) {
- var $parsed;
-
- // Status code 0 is not HTTP status code,
- // but is valid value of XMLHttpRequest status.
- // It is used for variety of network errors, for example
- // when an AJAX call was cancelled before getting the response
- if ( data && data.status === 0 ) {
- return {
- changes: 'NO_RESULTS',
- // We need empty result set, to avoid exceptions because of undefined value
- fieldset: $( [] ),
- noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
- };
- }
-
- $parsed = $( '<div>' ).append( $( $.parseHTML(
- data ? data.content : ''
- ) ) );
- return this._extractChangesListInfo( $parsed, data.status );
- }.bind( this )
- );
- };
+ $parsed = $( '<div>' ).append( $( $.parseHTML(
+ data ? data.content : ''
+ ) ) );
- /**
- * Track usage of highlight feature
- *
- * @param {string} action
- * @param {Array|Object|string} filters
- */
- Controller.prototype._trackHighlight = function ( action, filters ) {
- filters = typeof filters === 'string' ? { name: filters } : filters;
- filters = !Array.isArray( filters ) ? [ filters ] : filters;
- mw.track(
- 'event.ChangesListHighlights',
- {
- action: action,
- filters: filters,
- userId: mw.user.getId()
- }
+ return this._extractChangesListInfo( $parsed, data.status );
+ }.bind( this )
);
- };
-
- /**
- * Track filter grouping usage
- *
- * @param {string} action Action taken
- */
- Controller.prototype.trackFilterGroupings = function ( action ) {
- var controller = this,
- rightNow = new Date().getTime(),
- randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
- // Get all current filters
- filters = this.filtersModel.findSelectedItems().map( function ( item ) {
- return item.getName();
- } );
-
- action = action || 'filtermenu';
-
- // Check if these filters were the ones we just logged previously
- // (Don't log the same grouping twice, in case the user opens/closes)
- // the menu without action, or with the same result
- if (
- // Only log if the two arrays are different in size
- filters.length !== this.prevLoggedItems.length ||
- // Or if any filters are not the same as the cached filters
- filters.some( function ( filterName ) {
- return controller.prevLoggedItems.indexOf( filterName ) === -1;
- } ) ||
- // Or if any cached filters are not the same as given filters
- this.prevLoggedItems.some( function ( filterName ) {
- return filters.indexOf( filterName ) === -1;
- } )
- ) {
- filters.forEach( function ( filterName ) {
- mw.track(
- 'event.ChangesListFilterGrouping',
- {
- action: action,
- groupIdentifier: randomIdentifier,
- filter: filterName,
- userId: mw.user.getId()
- }
- );
- } );
-
- // Cache the filter names
- this.prevLoggedItems = filters;
+};
+
+/**
+ * Track usage of highlight feature
+ *
+ * @param {string} action
+ * @param {Array|Object|string} filters
+ */
+Controller.prototype._trackHighlight = function ( action, filters ) {
+ filters = typeof filters === 'string' ? { name: filters } : filters;
+ filters = !Array.isArray( filters ) ? [ filters ] : filters;
+ mw.track(
+ 'event.ChangesListHighlights',
+ {
+ action: action,
+ filters: filters,
+ userId: mw.user.getId()
}
- };
-
- /**
- * Apply a change of parameters to the model state, and check whether
- * the new state is different than the old state.
- *
- * @param {Object} newParamState New parameter state to apply
- * @return {boolean} New applied model state is different than the previous state
- */
- Controller.prototype.applyParamChange = function ( newParamState ) {
- var after,
- before = this.filtersModel.getSelectedState();
-
- this.filtersModel.updateStateFromParams( newParamState );
-
- after = this.filtersModel.getSelectedState();
-
- return !OO.compare( before, after );
- };
-
- /**
- * Mark all changes as seen on Watchlist
- */
- Controller.prototype.markAllChangesAsSeen = function () {
- var api = new mw.Api();
- api.postWithToken( 'csrf', {
- formatversion: 2,
- action: 'setnotificationtimestamp',
- entirewatchlist: true
- } ).then( function () {
- this.updateChangesList( null, 'markSeen' );
- }.bind( this ) );
- };
-
- /**
- * Set the current search for the system.
- *
- * @param {string} searchQuery Search query, including triggers
- */
- Controller.prototype.setSearch = function ( searchQuery ) {
- this.filtersModel.setSearch( searchQuery );
- };
-
- /**
- * Switch the view by changing the search query trigger
- * without changing the search term
- *
- * @param {string} view View to change to
- */
- Controller.prototype.switchView = function ( view ) {
- this.setSearch(
- this.filtersModel.getViewTrigger( view ) +
- this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
- );
- };
+ );
+};
+
+/**
+ * Track filter grouping usage
+ *
+ * @param {string} action Action taken
+ */
+Controller.prototype.trackFilterGroupings = function ( action ) {
+ var controller = this,
+ rightNow = new Date().getTime(),
+ randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
+ // Get all current filters
+ filters = this.filtersModel.findSelectedItems().map( function ( item ) {
+ return item.getName();
+ } );
- /**
- * Reset the search for a specific view. This means we null the search query
- * and replace it with the relevant trigger for the requested view
- *
- * @param {string} [view='default'] View to change to
- */
- Controller.prototype.resetSearchForView = function ( view ) {
- view = view || 'default';
-
- this.setSearch(
- this.filtersModel.getViewTrigger( view )
- );
- };
+ action = action || 'filtermenu';
+
+ // Check if these filters were the ones we just logged previously
+ // (Don't log the same grouping twice, in case the user opens/closes)
+ // the menu without action, or with the same result
+ if (
+ // Only log if the two arrays are different in size
+ filters.length !== this.prevLoggedItems.length ||
+ // Or if any filters are not the same as the cached filters
+ filters.some( function ( filterName ) {
+ return controller.prevLoggedItems.indexOf( filterName ) === -1;
+ } ) ||
+ // Or if any cached filters are not the same as given filters
+ this.prevLoggedItems.some( function ( filterName ) {
+ return filters.indexOf( filterName ) === -1;
+ } )
+ ) {
+ filters.forEach( function ( filterName ) {
+ mw.track(
+ 'event.ChangesListFilterGrouping',
+ {
+ action: action,
+ groupIdentifier: randomIdentifier,
+ filter: filterName,
+ userId: mw.user.getId()
+ }
+ );
+ } );
- module.exports = Controller;
-}() );
+ // Cache the filter names
+ this.prevLoggedItems = filters;
+ }
+};
+
+/**
+ * Apply a change of parameters to the model state, and check whether
+ * the new state is different than the old state.
+ *
+ * @param {Object} newParamState New parameter state to apply
+ * @return {boolean} New applied model state is different than the previous state
+ */
+Controller.prototype.applyParamChange = function ( newParamState ) {
+ var after,
+ before = this.filtersModel.getSelectedState();
+
+ this.filtersModel.updateStateFromParams( newParamState );
+
+ after = this.filtersModel.getSelectedState();
+
+ return !OO.compare( before, after );
+};
+
+/**
+ * Mark all changes as seen on Watchlist
+ */
+Controller.prototype.markAllChangesAsSeen = function () {
+ var api = new mw.Api();
+ api.postWithToken( 'csrf', {
+ formatversion: 2,
+ action: 'setnotificationtimestamp',
+ entirewatchlist: true
+ } ).then( function () {
+ this.updateChangesList( null, 'markSeen' );
+ }.bind( this ) );
+};
+
+/**
+ * Set the current search for the system.
+ *
+ * @param {string} searchQuery Search query, including triggers
+ */
+Controller.prototype.setSearch = function ( searchQuery ) {
+ this.filtersModel.setSearch( searchQuery );
+};
+
+/**
+ * Switch the view by changing the search query trigger
+ * without changing the search term
+ *
+ * @param {string} view View to change to
+ */
+Controller.prototype.switchView = function ( view ) {
+ this.setSearch(
+ this.filtersModel.getViewTrigger( view ) +
+ this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
+ );
+};
+
+/**
+ * Reset the search for a specific view. This means we null the search query
+ * and replace it with the relevant trigger for the requested view
+ *
+ * @param {string} [view='default'] View to change to
+ */
+Controller.prototype.resetSearchForView = function ( view ) {
+ view = view || 'default';
+
+ this.setSearch(
+ this.filtersModel.getViewTrigger( view )
+ );
+};
+
+module.exports = Controller;
-( function () {
- /**
- * Supported highlight colors.
- * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
- *
- * @member mw.rcfilters
- * @property {string[]}
- */
- var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
+/**
+ * Supported highlight colors.
+ * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
+ *
+ * @member mw.rcfilters
+ * @property {string[]}
+ */
+var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
- module.exports = HighlightColors;
-}() );
+module.exports = HighlightColors;
-( function () {
- /* eslint no-underscore-dangle: "off" */
- /**
- * URI Processor for RCFilters
- *
- * @class mw.rcfilters.UriProcessor
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
- * @param {Object} [config] Configuration object
- * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
- * title normalization to separate title subpage/parts into the target= url
- * parameter
- */
- var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
- config = config || {};
- this.filtersModel = filtersModel;
-
- this.normalizeTarget = !!config.normalizeTarget;
- };
-
- /* Initialization */
- OO.initClass( UriProcessor );
-
- /* Static methods */
-
- /**
- * Replace the url history through replaceState
- *
- * @param {mw.Uri} newUri New URI to replace
- */
- UriProcessor.static.replaceState = function ( newUri ) {
- window.history.replaceState(
- { tag: 'rcfilters' },
- document.title,
- newUri.toString()
- );
- };
-
- /**
- * Push the url to history through pushState
- *
- * @param {mw.Uri} newUri New URI to push
- */
- UriProcessor.static.pushState = function ( newUri ) {
- window.history.pushState(
- { tag: 'rcfilters' },
- document.title,
- newUri.toString()
- );
- };
-
- /* Methods */
-
- /**
- * Get the version that this URL query is tagged with.
- *
- * @param {Object} [uriQuery] URI query
- * @return {number} URL version
- */
- UriProcessor.prototype.getVersion = function ( uriQuery ) {
- uriQuery = uriQuery || new mw.Uri().query;
-
- return Number( uriQuery.urlversion || 1 );
- };
-
- /**
- * Get an updated mw.Uri object based on the model state
- *
- * @param {mw.Uri} [uri] An external URI to build the new uri
- * with. This is mainly for tests, to be able to supply external query
- * parameters and make sure they are retained.
- * @return {mw.Uri} Updated Uri
- */
- UriProcessor.prototype.getUpdatedUri = function ( uri ) {
- var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
- unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
-
- normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
- $.extend(
- true,
- {},
- normalizedUri.query,
- // The representation must be expanded so it can
- // override the uri query params but we then output
- // a minimized version for the entire URI representation
- // for the method
- this.filtersModel.getExpandedParamRepresentation()
- )
- );
-
- // Reapply unrecognized params and url version
- normalizedUri.query = $.extend(
+/* eslint no-underscore-dangle: "off" */
+/**
+ * URI Processor for RCFilters
+ *
+ * @class mw.rcfilters.UriProcessor
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+ * @param {Object} [config] Configuration object
+ * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+ * title normalization to separate title subpage/parts into the target= url
+ * parameter
+ */
+var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
+ config = config || {};
+ this.filtersModel = filtersModel;
+
+ this.normalizeTarget = !!config.normalizeTarget;
+};
+
+/* Initialization */
+OO.initClass( UriProcessor );
+
+/* Static methods */
+
+/**
+ * Replace the url history through replaceState
+ *
+ * @param {mw.Uri} newUri New URI to replace
+ */
+UriProcessor.static.replaceState = function ( newUri ) {
+ window.history.replaceState(
+ { tag: 'rcfilters' },
+ document.title,
+ newUri.toString()
+ );
+};
+
+/**
+ * Push the url to history through pushState
+ *
+ * @param {mw.Uri} newUri New URI to push
+ */
+UriProcessor.static.pushState = function ( newUri ) {
+ window.history.pushState(
+ { tag: 'rcfilters' },
+ document.title,
+ newUri.toString()
+ );
+};
+
+/* Methods */
+
+/**
+ * Get the version that this URL query is tagged with.
+ *
+ * @param {Object} [uriQuery] URI query
+ * @return {number} URL version
+ */
+UriProcessor.prototype.getVersion = function ( uriQuery ) {
+ uriQuery = uriQuery || new mw.Uri().query;
+
+ return Number( uriQuery.urlversion || 1 );
+};
+
+/**
+ * Get an updated mw.Uri object based on the model state
+ *
+ * @param {mw.Uri} [uri] An external URI to build the new uri
+ * with. This is mainly for tests, to be able to supply external query
+ * parameters and make sure they are retained.
+ * @return {mw.Uri} Updated Uri
+ */
+UriProcessor.prototype.getUpdatedUri = function ( uri ) {
+ var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
+ unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
+
+ normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
+ $.extend(
true,
{},
normalizedUri.query,
- unrecognizedParams,
- { urlversion: '2' }
- );
-
- return normalizedUri;
- };
-
- /**
- * Move the subpage to the target parameter
- *
- * @param {mw.Uri} uri
- * @return {mw.Uri}
- * @private
- */
- UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
- var parts,
- // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
- re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
-
- if ( !this.normalizeTarget ) {
- return uri;
- }
-
- // target in title param
- if ( uri.query.title ) {
- parts = uri.query.title.match( re );
- if ( parts ) {
- uri.query.title = parts[ 1 ];
- uri.query.target = parts[ 2 ];
- }
- }
+ // The representation must be expanded so it can
+ // override the uri query params but we then output
+ // a minimized version for the entire URI representation
+ // for the method
+ this.filtersModel.getExpandedParamRepresentation()
+ )
+ );
+
+ // Reapply unrecognized params and url version
+ normalizedUri.query = $.extend(
+ true,
+ {},
+ normalizedUri.query,
+ unrecognizedParams,
+ { urlversion: '2' }
+ );
+
+ return normalizedUri;
+};
+
+/**
+ * Move the subpage to the target parameter
+ *
+ * @param {mw.Uri} uri
+ * @return {mw.Uri}
+ * @private
+ */
+UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
+ var parts,
+ // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
+ re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
+
+ if ( !this.normalizeTarget ) {
+ return uri;
+ }
- // target in path
- parts = mw.Uri.decode( uri.path ).match( re );
+ // target in title param
+ if ( uri.query.title ) {
+ parts = uri.query.title.match( re );
if ( parts ) {
- uri.path = parts[ 1 ];
+ uri.query.title = parts[ 1 ];
uri.query.target = parts[ 2 ];
}
-
- return uri;
- };
-
- /**
- * Get an object representing given parameters that are unrecognized by the model
- *
- * @param {Object} params Full params object
- * @return {Object} Unrecognized params
- */
- UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
- // Start with full representation
- var givenParamNames = Object.keys( params ),
- unrecognizedParams = $.extend( true, {}, params );
-
- // Extract unrecognized parameters
- Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
- // Remove recognized params
- if ( givenParamNames.indexOf( paramName ) > -1 ) {
- delete unrecognizedParams[ paramName ];
- }
- } );
-
- return unrecognizedParams;
- };
-
- /**
- * Update the URL of the page to reflect current filters
- *
- * This should not be called directly from outside the controller.
- * If an action requires changing the URL, it should either use the
- * highlighting actions below, or call #updateChangesList which does
- * the uri corrections already.
- *
- * @param {Object} [params] Extra parameters to add to the API call
- */
- UriProcessor.prototype.updateURL = function ( params ) {
- var currentUri = new mw.Uri(),
- updatedUri = this.getUpdatedUri();
-
- updatedUri.extend( params || {} );
-
- if (
- this.getVersion( currentUri.query ) !== 2 ||
- this.isNewState( currentUri.query, updatedUri.query )
- ) {
- this.constructor.static.replaceState( updatedUri );
+ }
+
+ // target in path
+ parts = mw.Uri.decode( uri.path ).match( re );
+ if ( parts ) {
+ uri.path = parts[ 1 ];
+ uri.query.target = parts[ 2 ];
+ }
+
+ return uri;
+};
+
+/**
+ * Get an object representing given parameters that are unrecognized by the model
+ *
+ * @param {Object} params Full params object
+ * @return {Object} Unrecognized params
+ */
+UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
+ // Start with full representation
+ var givenParamNames = Object.keys( params ),
+ unrecognizedParams = $.extend( true, {}, params );
+
+ // Extract unrecognized parameters
+ Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
+ // Remove recognized params
+ if ( givenParamNames.indexOf( paramName ) > -1 ) {
+ delete unrecognizedParams[ paramName ];
}
- };
-
- /**
- * Update the filters model based on the URI query
- * This happens on initialization, and from this moment on,
- * we consider the system synchronized, and the model serves
- * as the source of truth for the URL.
- *
- * This methods should only be called once on initialization.
- * After initialization, the model updates the URL, not the
- * other way around.
- *
- * @param {Object} [uriQuery] URI query
- */
- UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
- uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
- this.filtersModel.updateStateFromParams(
- this._getNormalizedQueryParams( uriQuery )
- );
- };
-
- /**
- * Compare two URI queries to decide whether they are different
- * enough to represent a new state.
- *
- * @param {Object} currentUriQuery Current Uri query
- * @param {Object} updatedUriQuery Updated Uri query
- * @return {boolean} This is a new state
- */
- UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
- var currentParamState, updatedParamState,
- notEquivalent = function ( obj1, obj2 ) {
- var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
- return keys.some( function ( key ) {
- return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
- } );
- };
-
- // Compare states instead of parameters
- // This will allow us to always have a proper check of whether
- // the requested new url is one to change or not, regardless of
- // actual parameter visibility/representation in the URL
- currentParamState = $.extend(
- true,
- {},
- this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
- this.getUnrecognizedParams( currentUriQuery )
- );
- updatedParamState = $.extend(
- true,
- {},
- this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
- this.getUnrecognizedParams( updatedUriQuery )
- );
-
- return notEquivalent( currentParamState, updatedParamState );
- };
-
- /**
- * Check whether the given query has parameters that are
- * recognized as parameters we should load the system with
- *
- * @param {mw.Uri} [uriQuery] Given URI query
- * @return {boolean} Query contains valid recognized parameters
- */
- UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
- var anyValidInUrl,
- validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
-
- uriQuery = uriQuery || new mw.Uri().query;
-
- anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
- return validParameterNames.indexOf( parameter ) > -1;
- } );
-
- // URL version 2 is allowed to be empty or within nonrecognized params
- return anyValidInUrl || this.getVersion( uriQuery ) === 2;
- };
-
- /**
- * Get the adjusted URI params based on the url version
- * If the urlversion is not 2, the parameters are merged with
- * the model's defaults.
- * Always merge in the hidden parameter defaults.
- *
- * @private
- * @param {Object} uriQuery Current URI query
- * @return {Object} Normalized parameters
- */
- UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
- // Check whether we are dealing with urlversion=2
- // If we are, we do not merge the initial request with
- // defaults. Not having urlversion=2 means we need to
- // reproduce the server-side request and merge the
- // requested parameters (or starting state) with the
- // wiki default.
- // Any subsequent change of the URL through the RCFilters
- // system will receive 'urlversion=2'
- var base = this.getVersion( uriQuery ) === 2 ?
- {} :
- this.filtersModel.getDefaultParams();
-
- return $.extend(
- true,
- {},
- this.filtersModel.getMinimizedParamRepresentation(
- $.extend( true, {}, base, uriQuery )
- ),
- { urlversion: '2' }
- );
- };
-
- module.exports = UriProcessor;
-}() );
+ } );
+
+ return unrecognizedParams;
+};
+
+/**
+ * Update the URL of the page to reflect current filters
+ *
+ * This should not be called directly from outside the controller.
+ * If an action requires changing the URL, it should either use the
+ * highlighting actions below, or call #updateChangesList which does
+ * the uri corrections already.
+ *
+ * @param {Object} [params] Extra parameters to add to the API call
+ */
+UriProcessor.prototype.updateURL = function ( params ) {
+ var currentUri = new mw.Uri(),
+ updatedUri = this.getUpdatedUri();
+
+ updatedUri.extend( params || {} );
+
+ if (
+ this.getVersion( currentUri.query ) !== 2 ||
+ this.isNewState( currentUri.query, updatedUri.query )
+ ) {
+ this.constructor.static.replaceState( updatedUri );
+ }
+};
+
+/**
+ * Update the filters model based on the URI query
+ * This happens on initialization, and from this moment on,
+ * we consider the system synchronized, and the model serves
+ * as the source of truth for the URL.
+ *
+ * This methods should only be called once on initialization.
+ * After initialization, the model updates the URL, not the
+ * other way around.
+ *
+ * @param {Object} [uriQuery] URI query
+ */
+UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
+ uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
+ this.filtersModel.updateStateFromParams(
+ this._getNormalizedQueryParams( uriQuery )
+ );
+};
+
+/**
+ * Compare two URI queries to decide whether they are different
+ * enough to represent a new state.
+ *
+ * @param {Object} currentUriQuery Current Uri query
+ * @param {Object} updatedUriQuery Updated Uri query
+ * @return {boolean} This is a new state
+ */
+UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
+ var currentParamState, updatedParamState,
+ notEquivalent = function ( obj1, obj2 ) {
+ var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
+ return keys.some( function ( key ) {
+ return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
+ } );
+ };
+
+ // Compare states instead of parameters
+ // This will allow us to always have a proper check of whether
+ // the requested new url is one to change or not, regardless of
+ // actual parameter visibility/representation in the URL
+ currentParamState = $.extend(
+ true,
+ {},
+ this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
+ this.getUnrecognizedParams( currentUriQuery )
+ );
+ updatedParamState = $.extend(
+ true,
+ {},
+ this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
+ this.getUnrecognizedParams( updatedUriQuery )
+ );
+
+ return notEquivalent( currentParamState, updatedParamState );
+};
+
+/**
+ * Check whether the given query has parameters that are
+ * recognized as parameters we should load the system with
+ *
+ * @param {mw.Uri} [uriQuery] Given URI query
+ * @return {boolean} Query contains valid recognized parameters
+ */
+UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
+ var anyValidInUrl,
+ validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
+
+ uriQuery = uriQuery || new mw.Uri().query;
+
+ anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
+ return validParameterNames.indexOf( parameter ) > -1;
+ } );
+
+ // URL version 2 is allowed to be empty or within nonrecognized params
+ return anyValidInUrl || this.getVersion( uriQuery ) === 2;
+};
+
+/**
+ * Get the adjusted URI params based on the url version
+ * If the urlversion is not 2, the parameters are merged with
+ * the model's defaults.
+ * Always merge in the hidden parameter defaults.
+ *
+ * @private
+ * @param {Object} uriQuery Current URI query
+ * @return {Object} Normalized parameters
+ */
+UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
+ // Check whether we are dealing with urlversion=2
+ // If we are, we do not merge the initial request with
+ // defaults. Not having urlversion=2 means we need to
+ // reproduce the server-side request and merge the
+ // requested parameters (or starting state) with the
+ // wiki default.
+ // Any subsequent change of the URL through the RCFilters
+ // system will receive 'urlversion=2'
+ var base = this.getVersion( uriQuery ) === 2 ?
+ {} :
+ this.filtersModel.getDefaultParams();
+
+ return $.extend(
+ true,
+ {},
+ this.filtersModel.getMinimizedParamRepresentation(
+ $.extend( true, {}, base, uriQuery )
+ ),
+ { urlversion: '2' }
+ );
+};
+
+module.exports = UriProcessor;
-( function () {
- /**
- * View model for the changes list
- *
- * @class mw.rcfilters.dm.ChangesListViewModel
- * @mixins OO.EventEmitter
- *
- * @param {jQuery} $initialFieldset The initial server-generated legacy form content
- * @constructor
- */
- var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- this.valid = true;
- this.newChangesExist = false;
- this.liveUpdate = false;
- this.unseenWatchedChanges = false;
-
- this.extractNextFrom( $initialFieldset );
- };
-
- /* Initialization */
- OO.initClass( ChangesListViewModel );
- OO.mixinClass( ChangesListViewModel, OO.EventEmitter );
-
- /* Events */
-
- /**
- * @event invalidate
- *
- * The list of changes is now invalid (out of date)
- */
-
- /**
- * @event update
- * @param {jQuery|string} $changesListContent List of changes
- * @param {jQuery} $fieldset Server-generated form
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
- * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
- *
- * The list of changes has been updated
- */
-
- /**
- * @event newChangesExist
- * @param {boolean} newChangesExist
- *
- * The existence of changes newer than those currently displayed has changed.
- */
-
- /**
- * @event liveUpdateChange
- * @param {boolean} enable
- *
- * The state of the 'live update' feature has changed.
- */
-
- /* Methods */
-
- /**
- * Invalidate the list of changes
- *
- * @fires invalidate
- */
- ChangesListViewModel.prototype.invalidate = function () {
- if ( this.valid ) {
- this.valid = false;
- this.emit( 'invalidate' );
- }
- };
-
- /**
- * Update the model with an updated list of changes
- *
- * @param {jQuery|string} changesListContent
- * @param {jQuery} $fieldset
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
- * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
- * @fires update
- */
- ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
- var from = this.nextFrom;
- this.valid = true;
- this.extractNextFrom( $fieldset );
- this.checkForUnseenWatchedChanges( changesListContent );
- this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
- };
-
- /**
- * Specify whether new changes exist
- *
- * @param {boolean} newChangesExist
- * @fires newChangesExist
- */
- ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
- if ( newChangesExist !== this.newChangesExist ) {
- this.newChangesExist = newChangesExist;
- this.emit( 'newChangesExist', newChangesExist );
- }
- };
-
- /**
- * @return {boolean} Whether new changes exist
- */
- ChangesListViewModel.prototype.getNewChangesExist = function () {
- return this.newChangesExist;
- };
-
- /**
- * Extract the value of the 'from' parameter from a link in the field set
- *
- * @param {jQuery} $fieldset
- */
- ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
- var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
- if ( data && data.from ) {
- this.nextFrom = data.from;
- }
- };
-
- /**
- * @return {string} The 'from' parameter that can be used to query new changes
- */
- ChangesListViewModel.prototype.getNextFrom = function () {
- return this.nextFrom;
- };
-
- /**
- * Toggle the 'live update' feature on/off
- *
- * @param {boolean} enable
- */
- ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
- enable = enable === undefined ? !this.liveUpdate : enable;
- if ( enable !== this.liveUpdate ) {
- this.liveUpdate = enable;
- this.emit( 'liveUpdateChange', this.liveUpdate );
- }
- };
-
- /**
- * @return {boolean} The 'live update' feature is enabled
- */
- ChangesListViewModel.prototype.getLiveUpdate = function () {
- return this.liveUpdate;
- };
-
- /**
- * Check if some of the given changes watched and unseen
- *
- * @param {jQuery|string} changeslistContent
- */
- ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
- this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
- changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
- };
-
- /**
- * @return {boolean} Whether some of the current changes are watched and unseen
- */
- ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
- return this.unseenWatchedChanges;
- };
-
- module.exports = ChangesListViewModel;
-}() );
+/**
+ * View model for the changes list
+ *
+ * @class mw.rcfilters.dm.ChangesListViewModel
+ * @mixins OO.EventEmitter
+ *
+ * @param {jQuery} $initialFieldset The initial server-generated legacy form content
+ * @constructor
+ */
+var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ this.valid = true;
+ this.newChangesExist = false;
+ this.liveUpdate = false;
+ this.unseenWatchedChanges = false;
+
+ this.extractNextFrom( $initialFieldset );
+};
+
+/* Initialization */
+OO.initClass( ChangesListViewModel );
+OO.mixinClass( ChangesListViewModel, OO.EventEmitter );
+
+/* Events */
+
+/**
+ * @event invalidate
+ *
+ * The list of changes is now invalid (out of date)
+ */
+
+/**
+ * @event update
+ * @param {jQuery|string} $changesListContent List of changes
+ * @param {jQuery} $fieldset Server-generated form
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
+ * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
+ *
+ * The list of changes has been updated
+ */
+
+/**
+ * @event newChangesExist
+ * @param {boolean} newChangesExist
+ *
+ * The existence of changes newer than those currently displayed has changed.
+ */
+
+/**
+ * @event liveUpdateChange
+ * @param {boolean} enable
+ *
+ * The state of the 'live update' feature has changed.
+ */
+
+/* Methods */
+
+/**
+ * Invalidate the list of changes
+ *
+ * @fires invalidate
+ */
+ChangesListViewModel.prototype.invalidate = function () {
+ if ( this.valid ) {
+ this.valid = false;
+ this.emit( 'invalidate' );
+ }
+};
+
+/**
+ * Update the model with an updated list of changes
+ *
+ * @param {jQuery|string} changesListContent
+ * @param {jQuery} $fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
+ * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
+ * @fires update
+ */
+ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
+ var from = this.nextFrom;
+ this.valid = true;
+ this.extractNextFrom( $fieldset );
+ this.checkForUnseenWatchedChanges( changesListContent );
+ this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
+};
+
+/**
+ * Specify whether new changes exist
+ *
+ * @param {boolean} newChangesExist
+ * @fires newChangesExist
+ */
+ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
+ if ( newChangesExist !== this.newChangesExist ) {
+ this.newChangesExist = newChangesExist;
+ this.emit( 'newChangesExist', newChangesExist );
+ }
+};
+
+/**
+ * @return {boolean} Whether new changes exist
+ */
+ChangesListViewModel.prototype.getNewChangesExist = function () {
+ return this.newChangesExist;
+};
+
+/**
+ * Extract the value of the 'from' parameter from a link in the field set
+ *
+ * @param {jQuery} $fieldset
+ */
+ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
+ var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
+ if ( data && data.from ) {
+ this.nextFrom = data.from;
+ }
+};
+
+/**
+ * @return {string} The 'from' parameter that can be used to query new changes
+ */
+ChangesListViewModel.prototype.getNextFrom = function () {
+ return this.nextFrom;
+};
+
+/**
+ * Toggle the 'live update' feature on/off
+ *
+ * @param {boolean} enable
+ */
+ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
+ enable = enable === undefined ? !this.liveUpdate : enable;
+ if ( enable !== this.liveUpdate ) {
+ this.liveUpdate = enable;
+ this.emit( 'liveUpdateChange', this.liveUpdate );
+ }
+};
+
+/**
+ * @return {boolean} The 'live update' feature is enabled
+ */
+ChangesListViewModel.prototype.getLiveUpdate = function () {
+ return this.liveUpdate;
+};
+
+/**
+ * Check if some of the given changes watched and unseen
+ *
+ * @param {jQuery|string} changeslistContent
+ */
+ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
+ this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
+ changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
+};
+
+/**
+ * @return {boolean} Whether some of the current changes are watched and unseen
+ */
+ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
+ return this.unseenWatchedChanges;
+};
+
+module.exports = ChangesListViewModel;
-( function () {
- var FilterItem = require( './FilterItem.js' ),
- FilterGroup;
-
- /**
- * View model for a filter group
- *
- * @class mw.rcfilters.dm.FilterGroup
- * @mixins OO.EventEmitter
- * @mixins OO.EmitterList
- *
- * @constructor
- * @param {string} name Group name
- * @param {Object} [config] Configuration options
- * @cfg {string} [type='send_unselected_if_any'] Group type
- * @cfg {string} [view='default'] Name of the display group this group
- * is a part of.
- * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
- * with a preference, does not participate in Saved Queries, and is
- * not shown in the active filters area.
- * @cfg {string} [title] Group title
- * @cfg {boolean} [hidden] This group is hidden from the regular menu views
- * and the active filters area.
- * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
- * group from the URL, even if it wasn't initially set up.
- * @cfg {number} [range] An object defining minimum and maximum values for numeric
- * groups. { min: x, max: y }
- * @cfg {number} [minValue] Minimum value for numeric groups
- * @cfg {string} [separator='|'] Value separator for 'string_options' groups
- * @cfg {boolean} [active] Group is active
- * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
- * @cfg {Object} [conflicts] Defines the conflicts for this filter group
- * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
- * group. If the prefix has 'invert' state, the parameter is expected to be an object
- * with 'default' and 'inverted' as keys.
- * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
- * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
- * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
- * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
- * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
- * @cfg {boolean} [visible=true] The visibility of the group
- */
- FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
- OO.EmitterList.call( this );
-
- this.name = name;
- this.type = config.type || 'send_unselected_if_any';
- this.view = config.view || 'default';
- this.sticky = !!config.sticky;
- this.title = config.title || name;
- this.hidden = !!config.hidden;
- this.allowArbitrary = !!config.allowArbitrary;
- this.numericRange = config.range;
- this.separator = config.separator || '|';
- this.labelPrefixKey = config.labelPrefixKey;
- this.visible = config.visible === undefined ? true : !!config.visible;
-
- this.currSelected = null;
- this.active = !!config.active;
- this.fullCoverage = !!config.fullCoverage;
-
- this.whatsThis = config.whatsThis || {};
-
- this.conflicts = config.conflicts || {};
- this.defaultParams = {};
- this.defaultFilters = {};
-
- this.aggregate( { update: 'filterItemUpdate' } );
- this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
- };
-
- /* Initialization */
- OO.initClass( FilterGroup );
- OO.mixinClass( FilterGroup, OO.EventEmitter );
- OO.mixinClass( FilterGroup, OO.EmitterList );
-
- /* Events */
-
- /**
- * @event update
- *
- * Group state has been updated
- */
-
- /* Methods */
-
- /**
- * Initialize the group and create its filter items
- *
- * @param {Object} filterDefinition Filter definition for this group
- * @param {string|Object} [groupDefault] Definition of the group default
- */
- FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
- var defaultParam,
- supersetMap = {},
- model = this,
- items = [];
-
- filterDefinition.forEach( function ( filter ) {
- // Instantiate an item
- var subsetNames = [],
- filterItem = new FilterItem( filter.name, model, {
- group: model.getName(),
- label: filter.label || filter.name,
- description: filter.description || '',
- labelPrefixKey: model.labelPrefixKey,
- cssClass: filter.cssClass,
- identifiers: filter.identifiers,
- defaultHighlightColor: filter.defaultHighlightColor
- } );
-
- if ( filter.subset ) {
- filter.subset = filter.subset.map( function ( el ) {
- return el.filter;
- } );
-
- subsetNames = [];
-
- filter.subset.forEach( function ( subsetFilterName ) {
- // Subsets (unlike conflicts) are always inside the same group
- // We can re-map the names of the filters we are getting from
- // the subsets with the group prefix
- var subsetName = model.getPrefixedName( subsetFilterName );
- // For convenience, we should store each filter's "supersets" -- these are
- // the filters that have that item in their subset list. This will just
- // make it easier to go through whether the item has any other items
- // that affect it (and are selected) at any given time
- supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
- mw.rcfilters.utils.addArrayElementsUnique(
- supersetMap[ subsetName ],
- filterItem.getName()
- );
-
- // Translate subset param name to add the group name, so we
- // get consistent naming. We know that subsets are only within
- // the same group
- subsetNames.push( subsetName );
- } );
-
- // Set translated subset
- filterItem.setSubset( subsetNames );
- }
-
- items.push( filterItem );
-
- // Store default parameter state; in this case, default is defined per filter
- if (
- model.getType() === 'send_unselected_if_any' ||
- model.getType() === 'boolean'
- ) {
- // Store the default parameter state
- // For this group type, parameter values are direct
- // We need to convert from a boolean to a string ('1' and '0')
- model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
- } else if ( model.getType() === 'any_value' ) {
- model.defaultParams[ filter.name ] = filter.default;
- }
- } );
-
- // Add items
- this.addItems( items );
-
- // Now that we have all items, we can apply the superset map
- this.getItems().forEach( function ( filterItem ) {
- filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
- } );
-
- // Store default parameter state; in this case, default is defined per the
- // entire group, given by groupDefault method parameter
- if ( this.getType() === 'string_options' ) {
- // Store the default parameter group state
- // For this group, the parameter is group name and value is the names
- // of selected items
- this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
- // Current values
- groupDefault ?
- groupDefault.split( this.getSeparator() ) :
- [],
- // Legal values
- this.getItems().map( function ( item ) {
- return item.getParamName();
- } )
- ).join( this.getSeparator() );
- } else if ( this.getType() === 'single_option' ) {
- defaultParam = groupDefault !== undefined ?
- groupDefault : this.getItems()[ 0 ].getParamName();
-
- // For this group, the parameter is the group name,
- // and a single item can be selected: default or first item
- this.defaultParams[ this.getName() ] = defaultParam;
- }
-
- // add highlights to defaultParams
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isHighlighted() ) {
- this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
- }
- }.bind( this ) );
-
- // Store default filter state based on default params
- this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
+var FilterItem = require( './FilterItem.js' ),
+ FilterGroup;
+
+/**
+ * View model for a filter group
+ *
+ * @class mw.rcfilters.dm.FilterGroup
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ * @param {string} name Group name
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [type='send_unselected_if_any'] Group type
+ * @cfg {string} [view='default'] Name of the display group this group
+ * is a part of.
+ * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
+ * with a preference, does not participate in Saved Queries, and is
+ * not shown in the active filters area.
+ * @cfg {string} [title] Group title
+ * @cfg {boolean} [hidden] This group is hidden from the regular menu views
+ * and the active filters area.
+ * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
+ * group from the URL, even if it wasn't initially set up.
+ * @cfg {number} [range] An object defining minimum and maximum values for numeric
+ * groups. { min: x, max: y }
+ * @cfg {number} [minValue] Minimum value for numeric groups
+ * @cfg {string} [separator='|'] Value separator for 'string_options' groups
+ * @cfg {boolean} [active] Group is active
+ * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter group
+ * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+ * group. If the prefix has 'invert' state, the parameter is expected to be an object
+ * with 'default' and 'inverted' as keys.
+ * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
+ * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
+ * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
+ * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
+ * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
+ * @cfg {boolean} [visible=true] The visibility of the group
+ */
+FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+ OO.EmitterList.call( this );
+
+ this.name = name;
+ this.type = config.type || 'send_unselected_if_any';
+ this.view = config.view || 'default';
+ this.sticky = !!config.sticky;
+ this.title = config.title || name;
+ this.hidden = !!config.hidden;
+ this.allowArbitrary = !!config.allowArbitrary;
+ this.numericRange = config.range;
+ this.separator = config.separator || '|';
+ this.labelPrefixKey = config.labelPrefixKey;
+ this.visible = config.visible === undefined ? true : !!config.visible;
+
+ this.currSelected = null;
+ this.active = !!config.active;
+ this.fullCoverage = !!config.fullCoverage;
+
+ this.whatsThis = config.whatsThis || {};
+
+ this.conflicts = config.conflicts || {};
+ this.defaultParams = {};
+ this.defaultFilters = {};
+
+ this.aggregate( { update: 'filterItemUpdate' } );
+ this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
+};
+
+/* Initialization */
+OO.initClass( FilterGroup );
+OO.mixinClass( FilterGroup, OO.EventEmitter );
+OO.mixinClass( FilterGroup, OO.EmitterList );
+
+/* Events */
+
+/**
+ * @event update
+ *
+ * Group state has been updated
+ */
+
+/* Methods */
+
+/**
+ * Initialize the group and create its filter items
+ *
+ * @param {Object} filterDefinition Filter definition for this group
+ * @param {string|Object} [groupDefault] Definition of the group default
+ */
+FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
+ var defaultParam,
+ supersetMap = {},
+ model = this,
+ items = [];
+
+ filterDefinition.forEach( function ( filter ) {
+ // Instantiate an item
+ var subsetNames = [],
+ filterItem = new FilterItem( filter.name, model, {
+ group: model.getName(),
+ label: filter.label || filter.name,
+ description: filter.description || '',
+ labelPrefixKey: model.labelPrefixKey,
+ cssClass: filter.cssClass,
+ identifiers: filter.identifiers,
+ defaultHighlightColor: filter.defaultHighlightColor
+ } );
- // Check for filters that should be initially selected by their default value
- if ( this.isSticky() ) {
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.defaultFilters, function ( filterName, filterValue ) {
- model.getItemByName( filterName ).toggleSelected( filterValue );
+ if ( filter.subset ) {
+ filter.subset = filter.subset.map( function ( el ) {
+ return el.filter;
} );
- }
- // Verify that single_option group has at least one item selected
- if (
- this.getType() === 'single_option' &&
- this.findSelectedItems().length === 0
- ) {
- defaultParam = groupDefault !== undefined ?
- groupDefault : this.getItems()[ 0 ].getParamName();
+ subsetNames = [];
+
+ filter.subset.forEach( function ( subsetFilterName ) {
+ // Subsets (unlike conflicts) are always inside the same group
+ // We can re-map the names of the filters we are getting from
+ // the subsets with the group prefix
+ var subsetName = model.getPrefixedName( subsetFilterName );
+ // For convenience, we should store each filter's "supersets" -- these are
+ // the filters that have that item in their subset list. This will just
+ // make it easier to go through whether the item has any other items
+ // that affect it (and are selected) at any given time
+ supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
+ mw.rcfilters.utils.addArrayElementsUnique(
+ supersetMap[ subsetName ],
+ filterItem.getName()
+ );
+
+ // Translate subset param name to add the group name, so we
+ // get consistent naming. We know that subsets are only within
+ // the same group
+ subsetNames.push( subsetName );
+ } );
- // Single option means there must be a single option
- // selected, so we have to either select the default
- // or select the first option
- this.selectItemByParamName( defaultParam );
- }
- };
-
- /**
- * Respond to filterItem update event
- *
- * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
- * @fires update
- */
- FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
- // Update state
- var changed = false,
- active = this.areAnySelected(),
- model = this;
-
- if ( this.getType() === 'single_option' ) {
- // This group must have one item selected always
- // and must never have more than one item selected at a time
- if ( this.findSelectedItems().length === 0 ) {
- // Nothing is selected anymore
- // Select the default or the first item
- this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
- this.getItems()[ 0 ];
- this.currSelected.toggleSelected( true );
- changed = true;
- } else if ( this.findSelectedItems().length > 1 ) {
- // There is more than one item selected
- // This should only happen if the item given
- // is the one that is selected, so unselect
- // all items that is not it
- this.findSelectedItems().forEach( function ( itemModel ) {
- // Note that in case the given item is actually
- // not selected, this loop will end up unselecting
- // all items, which would trigger the case above
- // when the last item is unselected anyways
- var selected = itemModel.getName() === item.getName() &&
- item.isSelected();
-
- itemModel.toggleSelected( selected );
- if ( selected ) {
- model.currSelected = itemModel;
- }
- } );
- changed = true;
- }
+ // Set translated subset
+ filterItem.setSubset( subsetNames );
}
- if ( this.isSticky() ) {
- // If this group is sticky, then change the default according to the
- // current selection.
- this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
- }
+ items.push( filterItem );
+ // Store default parameter state; in this case, default is defined per filter
if (
- changed ||
- this.active !== active ||
- this.currSelected !== item
+ model.getType() === 'send_unselected_if_any' ||
+ model.getType() === 'boolean'
) {
- this.active = active;
- this.currSelected = item;
-
- this.emit( 'update' );
+ // Store the default parameter state
+ // For this group type, parameter values are direct
+ // We need to convert from a boolean to a string ('1' and '0')
+ model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
+ } else if ( model.getType() === 'any_value' ) {
+ model.defaultParams[ filter.name ] = filter.default;
}
- };
-
- /**
- * Get group active state
- *
- * @return {boolean} Active state
- */
- FilterGroup.prototype.isActive = function () {
- return this.active;
- };
-
- /**
- * Get group hidden state
- *
- * @return {boolean} Hidden state
- */
- FilterGroup.prototype.isHidden = function () {
- return this.hidden;
- };
-
- /**
- * Get group allow arbitrary state
- *
- * @return {boolean} Group allows an arbitrary value from the URL
- */
- FilterGroup.prototype.isAllowArbitrary = function () {
- return this.allowArbitrary;
- };
-
- /**
- * Get group maximum value for numeric groups
- *
- * @return {number|null} Group max value
- */
- FilterGroup.prototype.getMaxValue = function () {
- return this.numericRange && this.numericRange.max !== undefined ?
- this.numericRange.max : null;
- };
-
- /**
- * Get group minimum value for numeric groups
- *
- * @return {number|null} Group max value
- */
- FilterGroup.prototype.getMinValue = function () {
- return this.numericRange && this.numericRange.min !== undefined ?
- this.numericRange.min : null;
- };
-
- /**
- * Get group name
- *
- * @return {string} Group name
- */
- FilterGroup.prototype.getName = function () {
- return this.name;
- };
-
- /**
- * Get the default param state of this group
- *
- * @return {Object} Default param state
- */
- FilterGroup.prototype.getDefaultParams = function () {
- return this.defaultParams;
- };
-
- /**
- * Get the default filter state of this group
- *
- * @return {Object} Default filter state
- */
- FilterGroup.prototype.getDefaultFilters = function () {
- return this.defaultFilters;
- };
-
- /**
- * This is for a single_option and string_options group types
- * it returns the value of the default
- *
- * @return {string} Value of the default
- */
- FilterGroup.prototype.getDefaulParamValue = function () {
- return this.defaultParams[ this.getName() ];
- };
- /**
- * Get the messags defining the 'whats this' popup for this group
- *
- * @return {Object} What's this messages
- */
- FilterGroup.prototype.getWhatsThis = function () {
- return this.whatsThis;
- };
-
- /**
- * Check whether this group has a 'what's this' message
- *
- * @return {boolean} This group has a what's this message
- */
- FilterGroup.prototype.hasWhatsThis = function () {
- return !!this.whatsThis.body;
- };
-
- /**
- * Get the conflicts associated with the entire group.
- * Conflict object is set up by filter name keys and conflict
- * definition. For example:
- * [
- * {
- * filterName: {
- * filter: filterName,
- * group: group1
- * }
- * },
- * {
- * filterName2: {
- * filter: filterName2,
- * group: group2
- * }
- * }
- * ]
- * @return {Object} Conflict definition
- */
- FilterGroup.prototype.getConflicts = function () {
- return this.conflicts;
- };
-
- /**
- * Set conflicts for this group. See #getConflicts for the expected
- * structure of the definition.
- *
- * @param {Object} conflicts Conflicts for this group
- */
- FilterGroup.prototype.setConflicts = function ( conflicts ) {
- this.conflicts = conflicts;
- };
-
- /**
- * Set conflicts for each filter item in the group based on the
- * given conflict map
- *
- * @param {Object} conflicts Object representing the conflict map,
- * keyed by the item name, where its value is an object for all its conflicts
- */
- FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
- this.getItems().forEach( function ( filterItem ) {
- if ( conflicts[ filterItem.getName() ] ) {
- filterItem.setConflicts( conflicts[ filterItem.getName() ] );
- }
- } );
- };
-
- /**
- * Check whether this item has a potential conflict with the given item
- *
- * This checks whether the given item is in the list of conflicts of
- * the current item, but makes no judgment about whether the conflict
- * is currently at play (either one of the items may not be selected)
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
- * @return {boolean} This item has a conflict with the given item
- */
- FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
- return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
- };
-
- /**
- * Check whether there are any items selected
- *
- * @return {boolean} Any items in the group are selected
- */
- FilterGroup.prototype.areAnySelected = function () {
- return this.getItems().some( function ( filterItem ) {
- return filterItem.isSelected();
- } );
- };
+ } );
+
+ // Add items
+ this.addItems( items );
+
+ // Now that we have all items, we can apply the superset map
+ this.getItems().forEach( function ( filterItem ) {
+ filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+ } );
+
+ // Store default parameter state; in this case, default is defined per the
+ // entire group, given by groupDefault method parameter
+ if ( this.getType() === 'string_options' ) {
+ // Store the default parameter group state
+ // For this group, the parameter is group name and value is the names
+ // of selected items
+ this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
+ // Current values
+ groupDefault ?
+ groupDefault.split( this.getSeparator() ) :
+ [],
+ // Legal values
+ this.getItems().map( function ( item ) {
+ return item.getParamName();
+ } )
+ ).join( this.getSeparator() );
+ } else if ( this.getType() === 'single_option' ) {
+ defaultParam = groupDefault !== undefined ?
+ groupDefault : this.getItems()[ 0 ].getParamName();
+
+ // For this group, the parameter is the group name,
+ // and a single item can be selected: default or first item
+ this.defaultParams[ this.getName() ] = defaultParam;
+ }
+
+ // add highlights to defaultParams
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isHighlighted() ) {
+ this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
+ }
+ }.bind( this ) );
- /**
- * Check whether all items selected
- *
- * @return {boolean} All items are selected
- */
- FilterGroup.prototype.areAllSelected = function () {
- var selected = [],
- unselected = [];
+ // Store default filter state based on default params
+ this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isSelected() ) {
- selected.push( filterItem );
- } else {
- unselected.push( filterItem );
- }
+ // Check for filters that should be initially selected by their default value
+ if ( this.isSticky() ) {
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.defaultFilters, function ( filterName, filterValue ) {
+ model.getItemByName( filterName ).toggleSelected( filterValue );
} );
-
- if ( unselected.length === 0 ) {
- return true;
+ }
+
+ // Verify that single_option group has at least one item selected
+ if (
+ this.getType() === 'single_option' &&
+ this.findSelectedItems().length === 0
+ ) {
+ defaultParam = groupDefault !== undefined ?
+ groupDefault : this.getItems()[ 0 ].getParamName();
+
+ // Single option means there must be a single option
+ // selected, so we have to either select the default
+ // or select the first option
+ this.selectItemByParamName( defaultParam );
+ }
+};
+
+/**
+ * Respond to filterItem update event
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
+ * @fires update
+ */
+FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
+ // Update state
+ var changed = false,
+ active = this.areAnySelected(),
+ model = this;
+
+ if ( this.getType() === 'single_option' ) {
+ // This group must have one item selected always
+ // and must never have more than one item selected at a time
+ if ( this.findSelectedItems().length === 0 ) {
+ // Nothing is selected anymore
+ // Select the default or the first item
+ this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
+ this.getItems()[ 0 ];
+ this.currSelected.toggleSelected( true );
+ changed = true;
+ } else if ( this.findSelectedItems().length > 1 ) {
+ // There is more than one item selected
+ // This should only happen if the item given
+ // is the one that is selected, so unselect
+ // all items that is not it
+ this.findSelectedItems().forEach( function ( itemModel ) {
+ // Note that in case the given item is actually
+ // not selected, this loop will end up unselecting
+ // all items, which would trigger the case above
+ // when the last item is unselected anyways
+ var selected = itemModel.getName() === item.getName() &&
+ item.isSelected();
+
+ itemModel.toggleSelected( selected );
+ if ( selected ) {
+ model.currSelected = itemModel;
+ }
+ } );
+ changed = true;
}
+ }
+
+ if ( this.isSticky() ) {
+ // If this group is sticky, then change the default according to the
+ // current selection.
+ this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
+ }
+
+ if (
+ changed ||
+ this.active !== active ||
+ this.currSelected !== item
+ ) {
+ this.active = active;
+ this.currSelected = item;
+
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Get group active state
+ *
+ * @return {boolean} Active state
+ */
+FilterGroup.prototype.isActive = function () {
+ return this.active;
+};
+
+/**
+ * Get group hidden state
+ *
+ * @return {boolean} Hidden state
+ */
+FilterGroup.prototype.isHidden = function () {
+ return this.hidden;
+};
+
+/**
+ * Get group allow arbitrary state
+ *
+ * @return {boolean} Group allows an arbitrary value from the URL
+ */
+FilterGroup.prototype.isAllowArbitrary = function () {
+ return this.allowArbitrary;
+};
+
+/**
+ * Get group maximum value for numeric groups
+ *
+ * @return {number|null} Group max value
+ */
+FilterGroup.prototype.getMaxValue = function () {
+ return this.numericRange && this.numericRange.max !== undefined ?
+ this.numericRange.max : null;
+};
+
+/**
+ * Get group minimum value for numeric groups
+ *
+ * @return {number|null} Group max value
+ */
+FilterGroup.prototype.getMinValue = function () {
+ return this.numericRange && this.numericRange.min !== undefined ?
+ this.numericRange.min : null;
+};
+
+/**
+ * Get group name
+ *
+ * @return {string} Group name
+ */
+FilterGroup.prototype.getName = function () {
+ return this.name;
+};
+
+/**
+ * Get the default param state of this group
+ *
+ * @return {Object} Default param state
+ */
+FilterGroup.prototype.getDefaultParams = function () {
+ return this.defaultParams;
+};
+
+/**
+ * Get the default filter state of this group
+ *
+ * @return {Object} Default filter state
+ */
+FilterGroup.prototype.getDefaultFilters = function () {
+ return this.defaultFilters;
+};
+
+/**
+ * This is for a single_option and string_options group types
+ * it returns the value of the default
+ *
+ * @return {string} Value of the default
+ */
+FilterGroup.prototype.getDefaulParamValue = function () {
+ return this.defaultParams[ this.getName() ];
+};
+/**
+ * Get the messags defining the 'whats this' popup for this group
+ *
+ * @return {Object} What's this messages
+ */
+FilterGroup.prototype.getWhatsThis = function () {
+ return this.whatsThis;
+};
+
+/**
+ * Check whether this group has a 'what's this' message
+ *
+ * @return {boolean} This group has a what's this message
+ */
+FilterGroup.prototype.hasWhatsThis = function () {
+ return !!this.whatsThis.body;
+};
+
+/**
+ * Get the conflicts associated with the entire group.
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ * [
+ * {
+ * filterName: {
+ * filter: filterName,
+ * group: group1
+ * }
+ * },
+ * {
+ * filterName2: {
+ * filter: filterName2,
+ * group: group2
+ * }
+ * }
+ * ]
+ * @return {Object} Conflict definition
+ */
+FilterGroup.prototype.getConflicts = function () {
+ return this.conflicts;
+};
+
+/**
+ * Set conflicts for this group. See #getConflicts for the expected
+ * structure of the definition.
+ *
+ * @param {Object} conflicts Conflicts for this group
+ */
+FilterGroup.prototype.setConflicts = function ( conflicts ) {
+ this.conflicts = conflicts;
+};
+
+/**
+ * Set conflicts for each filter item in the group based on the
+ * given conflict map
+ *
+ * @param {Object} conflicts Object representing the conflict map,
+ * keyed by the item name, where its value is an object for all its conflicts
+ */
+FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
+ this.getItems().forEach( function ( filterItem ) {
+ if ( conflicts[ filterItem.getName() ] ) {
+ filterItem.setConflicts( conflicts[ filterItem.getName() ] );
+ }
+ } );
+};
+
+/**
+ * Check whether this item has a potential conflict with the given item
+ *
+ * This checks whether the given item is in the list of conflicts of
+ * the current item, but makes no judgment about whether the conflict
+ * is currently at play (either one of the items may not be selected)
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+ * @return {boolean} This item has a conflict with the given item
+ */
+FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
+ return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+};
+
+/**
+ * Check whether there are any items selected
+ *
+ * @return {boolean} Any items in the group are selected
+ */
+FilterGroup.prototype.areAnySelected = function () {
+ return this.getItems().some( function ( filterItem ) {
+ return filterItem.isSelected();
+ } );
+};
+
+/**
+ * Check whether all items selected
+ *
+ * @return {boolean} All items are selected
+ */
+FilterGroup.prototype.areAllSelected = function () {
+ var selected = [],
+ unselected = [];
+
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isSelected() ) {
+ selected.push( filterItem );
+ } else {
+ unselected.push( filterItem );
+ }
+ } );
- // check if every unselected is a subset of a selected
- return unselected.every( function ( unselectedFilterItem ) {
- return selected.some( function ( selectedFilterItem ) {
- return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
- } );
- } );
- };
-
- /**
- * Get all selected items in this group
- *
- * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
- * @return {mw.rcfilters.dm.FilterItem[]} Selected items
- */
- FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
- var excludeName = ( excludeItem && excludeItem.getName() ) || '';
-
- return this.getItems().filter( function ( item ) {
- return item.getName() !== excludeName && item.isSelected();
+ if ( unselected.length === 0 ) {
+ return true;
+ }
+
+ // check if every unselected is a subset of a selected
+ return unselected.every( function ( unselectedFilterItem ) {
+ return selected.some( function ( selectedFilterItem ) {
+ return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
} );
- };
-
- /**
- * Check whether all selected items are in conflict with the given item
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
- * @return {boolean} All selected items are in conflict with this item
- */
- FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
- var selectedItems = this.findSelectedItems( filterItem );
-
- return selectedItems.length > 0 &&
- (
- // The group as a whole is in conflict with this item
- this.existsInConflicts( filterItem ) ||
- // All selected items are in conflict individually
- selectedItems.every( function ( selectedFilter ) {
- return selectedFilter.existsInConflicts( filterItem );
- } )
- );
- };
-
- /**
- * Check whether any of the selected items are in conflict with the given item
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
- * @return {boolean} Any of the selected items are in conflict with this item
- */
- FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
- var selectedItems = this.findSelectedItems( filterItem );
-
- return selectedItems.length > 0 && (
+ } );
+};
+
+/**
+ * Get all selected items in this group
+ *
+ * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
+ * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+ */
+FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
+ var excludeName = ( excludeItem && excludeItem.getName() ) || '';
+
+ return this.getItems().filter( function ( item ) {
+ return item.getName() !== excludeName && item.isSelected();
+ } );
+};
+
+/**
+ * Check whether all selected items are in conflict with the given item
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+ * @return {boolean} All selected items are in conflict with this item
+ */
+FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
+ var selectedItems = this.findSelectedItems( filterItem );
+
+ return selectedItems.length > 0 &&
+ (
// The group as a whole is in conflict with this item
this.existsInConflicts( filterItem ) ||
- // Any selected items are in conflict individually
- selectedItems.some( function ( selectedFilter ) {
+ // All selected items are in conflict individually
+ selectedItems.every( function ( selectedFilter ) {
return selectedFilter.existsInConflicts( filterItem );
} )
);
- };
-
- /**
- * Get the parameter representation from this group
- *
- * @param {Object} [filterRepresentation] An object defining the state
- * of the filters in this group, keyed by their name and current selected
- * state value.
- * @return {Object} Parameter representation
- */
- FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
- var values,
- areAnySelected = false,
- buildFromCurrentState = !filterRepresentation,
- defaultFilters = this.getDefaultFilters(),
- result = {},
- model = this,
- filterParamNames = {},
- getSelectedParameter = function ( filters ) {
- var item,
- selected = [];
-
- // Find if any are selected
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( filters, function ( name, value ) {
- if ( value ) {
- selected.push( name );
- }
- } );
-
- item = model.getItemByName( selected[ 0 ] );
- return ( item && item.getParamName() ) || '';
- };
-
- filterRepresentation = filterRepresentation || {};
-
- // Create or complete the filterRepresentation definition
- this.getItems().forEach( function ( item ) {
- // Map filter names to their parameter names
- filterParamNames[ item.getName() ] = item.getParamName();
-
- if ( buildFromCurrentState ) {
- // This means we have not been given a filter representation
- // so we are building one based on current state
- filterRepresentation[ item.getName() ] = item.getValue();
- } else if ( filterRepresentation[ item.getName() ] === undefined ) {
- // We are given a filter representation, but we have to make
- // sure that we fill in the missing filters if there are any
- // we will assume they are all falsey
- if ( model.isSticky() ) {
- filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
- } else {
- filterRepresentation[ item.getName() ] = false;
- }
- }
-
- if ( filterRepresentation[ item.getName() ] ) {
- areAnySelected = true;
- }
- } );
-
- // Build result
- if (
- this.getType() === 'send_unselected_if_any' ||
- this.getType() === 'boolean' ||
- this.getType() === 'any_value'
- ) {
- // First, check if any of the items are selected at all.
- // If none is selected, we're treating it as if they are
- // all false
-
- // Go over the items and define the correct values
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( filterRepresentation, function ( name, value ) {
- // We must store all parameter values as strings '0' or '1'
- if ( model.getType() === 'send_unselected_if_any' ) {
- result[ filterParamNames[ name ] ] = areAnySelected ?
- String( Number( !value ) ) :
- '0';
- } else if ( model.getType() === 'boolean' ) {
- // Representation is straight-forward and direct from
- // the parameter value to the filter state
- result[ filterParamNames[ name ] ] = String( Number( !!value ) );
- } else if ( model.getType() === 'any_value' ) {
- result[ filterParamNames[ name ] ] = value;
- }
- } );
- } else if ( this.getType() === 'string_options' ) {
- values = [];
-
+};
+
+/**
+ * Check whether any of the selected items are in conflict with the given item
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+ * @return {boolean} Any of the selected items are in conflict with this item
+ */
+FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
+ var selectedItems = this.findSelectedItems( filterItem );
+
+ return selectedItems.length > 0 && (
+ // The group as a whole is in conflict with this item
+ this.existsInConflicts( filterItem ) ||
+ // Any selected items are in conflict individually
+ selectedItems.some( function ( selectedFilter ) {
+ return selectedFilter.existsInConflicts( filterItem );
+ } )
+ );
+};
+
+/**
+ * Get the parameter representation from this group
+ *
+ * @param {Object} [filterRepresentation] An object defining the state
+ * of the filters in this group, keyed by their name and current selected
+ * state value.
+ * @return {Object} Parameter representation
+ */
+FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
+ var values,
+ areAnySelected = false,
+ buildFromCurrentState = !filterRepresentation,
+ defaultFilters = this.getDefaultFilters(),
+ result = {},
+ model = this,
+ filterParamNames = {},
+ getSelectedParameter = function ( filters ) {
+ var item,
+ selected = [];
+
+ // Find if any are selected
// eslint-disable-next-line no-jquery/no-each-util
- $.each( filterRepresentation, function ( name, value ) {
- // Collect values
+ $.each( filters, function ( name, value ) {
if ( value ) {
- values.push( filterParamNames[ name ] );
+ selected.push( name );
}
} );
- result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
- 'all' : values.join( this.getSeparator() );
- } else if ( this.getType() === 'single_option' ) {
- result[ this.getName() ] = getSelectedParameter( filterRepresentation );
+ item = model.getItemByName( selected[ 0 ] );
+ return ( item && item.getParamName() ) || '';
+ };
+
+ filterRepresentation = filterRepresentation || {};
+
+ // Create or complete the filterRepresentation definition
+ this.getItems().forEach( function ( item ) {
+ // Map filter names to their parameter names
+ filterParamNames[ item.getName() ] = item.getParamName();
+
+ if ( buildFromCurrentState ) {
+ // This means we have not been given a filter representation
+ // so we are building one based on current state
+ filterRepresentation[ item.getName() ] = item.getValue();
+ } else if ( filterRepresentation[ item.getName() ] === undefined ) {
+ // We are given a filter representation, but we have to make
+ // sure that we fill in the missing filters if there are any
+ // we will assume they are all falsey
+ if ( model.isSticky() ) {
+ filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
+ } else {
+ filterRepresentation[ item.getName() ] = false;
+ }
}
- return result;
- };
-
- /**
- * Get the filter representation this group would provide
- * based on given parameter states.
- *
- * @param {Object} [paramRepresentation] An object defining a parameter
- * state to translate the filter state from. If not given, an object
- * representing all filters as falsey is returned; same as if the parameter
- * given were an empty object, or had some of the filters missing.
- * @return {Object} Filter representation
- */
- FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
- var areAnySelected, paramValues, item, currentValue,
- oneWasSelected = false,
- defaultParams = this.getDefaultParams(),
- expandedParams = $.extend( true, {}, paramRepresentation ),
- model = this,
- paramToFilterMap = {},
- result = {};
-
- if ( this.isSticky() ) {
- // If the group is sticky, check if all parameters are represented
- // and for those that aren't represented, add them with their default
- // values
- paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
+ if ( filterRepresentation[ item.getName() ] ) {
+ areAnySelected = true;
}
-
- paramRepresentation = paramRepresentation || {};
- if (
- this.getType() === 'send_unselected_if_any' ||
- this.getType() === 'boolean' ||
- this.getType() === 'any_value'
- ) {
- // Go over param representation; map and check for selections
- this.getItems().forEach( function ( filterItem ) {
- var paramName = filterItem.getParamName();
-
- expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
- paramToFilterMap[ paramName ] = filterItem;
-
- if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
- areAnySelected = true;
- }
- } );
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( expandedParams, function ( paramName, paramValue ) {
- var filterItem = paramToFilterMap[ paramName ];
-
- if ( model.getType() === 'send_unselected_if_any' ) {
- // Flip the definition between the parameter
- // state and the filter state
- // This is what the 'toggleSelected' value of the filter is
- result[ filterItem.getName() ] = areAnySelected ?
- !Number( paramValue ) :
- // Otherwise, there are no selected items in the
- // group, which means the state is false
- false;
- } else if ( model.getType() === 'boolean' ) {
- // Straight-forward definition of state
- result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
- } else if ( model.getType() === 'any_value' ) {
- result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
- }
- } );
- } else if ( this.getType() === 'string_options' ) {
- currentValue = paramRepresentation[ this.getName() ] || '';
-
- // Normalize the given parameter values
- paramValues = mw.rcfilters.utils.normalizeParamOptions(
- // Given
- currentValue.split(
- this.getSeparator()
- ),
- // Allowed values
- this.getItems().map( function ( filterItem ) {
- return filterItem.getParamName();
- } )
- );
- // Translate the parameter values into a filter selection state
- this.getItems().forEach( function ( filterItem ) {
- // All true (either because all values are written or the term 'all' is written)
- // is the same as all filters set to true
- result[ filterItem.getName() ] = (
- // If it is the word 'all'
- paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
- // All values are written
- paramValues.length === model.getItemCount()
- ) ?
- true :
- // Otherwise, the filter is selected only if it appears in the parameter values
- paramValues.indexOf( filterItem.getParamName() ) > -1;
- } );
- } else if ( this.getType() === 'single_option' ) {
- // There is parameter that fits a single filter and if not, get the default
- this.getItems().forEach( function ( filterItem ) {
- var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
-
- result[ filterItem.getName() ] = selected;
- oneWasSelected = oneWasSelected || selected;
- } );
- }
-
- // Go over result and make sure all filters are represented.
- // If any filters are missing, they will get a falsey value
- this.getItems().forEach( function ( filterItem ) {
- if ( result[ filterItem.getName() ] === undefined ) {
- result[ filterItem.getName() ] = this.getFalsyValue();
+ } );
+
+ // Build result
+ if (
+ this.getType() === 'send_unselected_if_any' ||
+ this.getType() === 'boolean' ||
+ this.getType() === 'any_value'
+ ) {
+ // First, check if any of the items are selected at all.
+ // If none is selected, we're treating it as if they are
+ // all false
+
+ // Go over the items and define the correct values
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( filterRepresentation, function ( name, value ) {
+ // We must store all parameter values as strings '0' or '1'
+ if ( model.getType() === 'send_unselected_if_any' ) {
+ result[ filterParamNames[ name ] ] = areAnySelected ?
+ String( Number( !value ) ) :
+ '0';
+ } else if ( model.getType() === 'boolean' ) {
+ // Representation is straight-forward and direct from
+ // the parameter value to the filter state
+ result[ filterParamNames[ name ] ] = String( Number( !!value ) );
+ } else if ( model.getType() === 'any_value' ) {
+ result[ filterParamNames[ name ] ] = value;
}
- }.bind( this ) );
-
- // Make sure that at least one option is selected in
- // single_option groups, no matter what path was taken
- // If none was selected by the given definition, then
- // we need to select the one in the base state -- either
- // the default given, or the first item
- if (
- this.getType() === 'single_option' &&
- !oneWasSelected
- ) {
- item = this.getItems()[ 0 ];
- if ( defaultParams[ this.getName() ] ) {
- item = this.getItemByParamName( defaultParams[ this.getName() ] );
+ } );
+ } else if ( this.getType() === 'string_options' ) {
+ values = [];
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( filterRepresentation, function ( name, value ) {
+ // Collect values
+ if ( value ) {
+ values.push( filterParamNames[ name ] );
}
+ } );
- result[ item.getName() ] = true;
- }
-
- return result;
- };
-
- /**
- * @return {*} The appropriate falsy value for this group type
- */
- FilterGroup.prototype.getFalsyValue = function () {
- return this.getType() === 'any_value' ? '' : false;
- };
+ result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
+ 'all' : values.join( this.getSeparator() );
+ } else if ( this.getType() === 'single_option' ) {
+ result[ this.getName() ] = getSelectedParameter( filterRepresentation );
+ }
+
+ return result;
+};
+
+/**
+ * Get the filter representation this group would provide
+ * based on given parameter states.
+ *
+ * @param {Object} [paramRepresentation] An object defining a parameter
+ * state to translate the filter state from. If not given, an object
+ * representing all filters as falsey is returned; same as if the parameter
+ * given were an empty object, or had some of the filters missing.
+ * @return {Object} Filter representation
+ */
+FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
+ var areAnySelected, paramValues, item, currentValue,
+ oneWasSelected = false,
+ defaultParams = this.getDefaultParams(),
+ expandedParams = $.extend( true, {}, paramRepresentation ),
+ model = this,
+ paramToFilterMap = {},
+ result = {};
+
+ if ( this.isSticky() ) {
+ // If the group is sticky, check if all parameters are represented
+ // and for those that aren't represented, add them with their default
+ // values
+ paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
+ }
+
+ paramRepresentation = paramRepresentation || {};
+ if (
+ this.getType() === 'send_unselected_if_any' ||
+ this.getType() === 'boolean' ||
+ this.getType() === 'any_value'
+ ) {
+ // Go over param representation; map and check for selections
+ this.getItems().forEach( function ( filterItem ) {
+ var paramName = filterItem.getParamName();
- /**
- * Get current selected state of all filter items in this group
- *
- * @return {Object} Selected state
- */
- FilterGroup.prototype.getSelectedState = function () {
- var state = {};
+ expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
+ paramToFilterMap[ paramName ] = filterItem;
- this.getItems().forEach( function ( filterItem ) {
- state[ filterItem.getName() ] = filterItem.getValue();
+ if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
+ areAnySelected = true;
+ }
} );
- return state;
- };
-
- /**
- * Get item by its filter name
- *
- * @param {string} filterName Filter name
- * @return {mw.rcfilters.dm.FilterItem} Filter item
- */
- FilterGroup.prototype.getItemByName = function ( filterName ) {
- return this.getItems().filter( function ( item ) {
- return item.getName() === filterName;
- } )[ 0 ];
- };
-
- /**
- * Select an item by its parameter name
- *
- * @param {string} paramName Filter parameter name
- */
- FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
- this.getItems().forEach( function ( item ) {
- item.toggleSelected( item.getParamName() === String( paramName ) );
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( expandedParams, function ( paramName, paramValue ) {
+ var filterItem = paramToFilterMap[ paramName ];
+
+ if ( model.getType() === 'send_unselected_if_any' ) {
+ // Flip the definition between the parameter
+ // state and the filter state
+ // This is what the 'toggleSelected' value of the filter is
+ result[ filterItem.getName() ] = areAnySelected ?
+ !Number( paramValue ) :
+ // Otherwise, there are no selected items in the
+ // group, which means the state is false
+ false;
+ } else if ( model.getType() === 'boolean' ) {
+ // Straight-forward definition of state
+ result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
+ } else if ( model.getType() === 'any_value' ) {
+ result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
+ }
} );
- };
-
- /**
- * Get item by its parameter name
- *
- * @param {string} paramName Parameter name
- * @return {mw.rcfilters.dm.FilterItem} Filter item
- */
- FilterGroup.prototype.getItemByParamName = function ( paramName ) {
- return this.getItems().filter( function ( item ) {
- return item.getParamName() === String( paramName );
- } )[ 0 ];
- };
-
- /**
- * Get group type
- *
- * @return {string} Group type
- */
- FilterGroup.prototype.getType = function () {
- return this.type;
- };
-
- /**
- * Check whether this group is represented by a single parameter
- * or whether each item is its own parameter
- *
- * @return {boolean} This group is a single parameter
- */
- FilterGroup.prototype.isPerGroupRequestParameter = function () {
- return (
- this.getType() === 'string_options' ||
- this.getType() === 'single_option'
+ } else if ( this.getType() === 'string_options' ) {
+ currentValue = paramRepresentation[ this.getName() ] || '';
+
+ // Normalize the given parameter values
+ paramValues = mw.rcfilters.utils.normalizeParamOptions(
+ // Given
+ currentValue.split(
+ this.getSeparator()
+ ),
+ // Allowed values
+ this.getItems().map( function ( filterItem ) {
+ return filterItem.getParamName();
+ } )
);
- };
-
- /**
- * Get display group
- *
- * @return {string} Display group
- */
- FilterGroup.prototype.getView = function () {
- return this.view;
- };
-
- /**
- * Get the prefix used for the filter names inside this group.
- *
- * @param {string} [name] Filter name to prefix
- * @return {string} Group prefix
- */
- FilterGroup.prototype.getNamePrefix = function () {
- return this.getName() + '__';
- };
-
- /**
- * Get a filter name with the prefix used for the filter names inside this group.
- *
- * @param {string} name Filter name to prefix
- * @return {string} Group prefix
- */
- FilterGroup.prototype.getPrefixedName = function ( name ) {
- return this.getNamePrefix() + name;
- };
-
- /**
- * Get group's title
- *
- * @return {string} Title
- */
- FilterGroup.prototype.getTitle = function () {
- return this.title;
- };
-
- /**
- * Get group's values separator
- *
- * @return {string} Values separator
- */
- FilterGroup.prototype.getSeparator = function () {
- return this.separator;
- };
-
- /**
- * Check whether the group is defined as full coverage
- *
- * @return {boolean} Group is full coverage
- */
- FilterGroup.prototype.isFullCoverage = function () {
- return this.fullCoverage;
- };
-
- /**
- * Check whether the group is defined as sticky default
- *
- * @return {boolean} Group is sticky default
- */
- FilterGroup.prototype.isSticky = function () {
- return this.sticky;
- };
-
- /**
- * Normalize a value given to this group. This is mostly for correcting
- * arbitrary values for 'single option' groups, given by the user settings
- * or the URL that can go outside the limits that are allowed.
- *
- * @param {string} value Given value
- * @return {string} Corrected value
- */
- FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
- if (
- this.getType() === 'single_option' &&
- this.isAllowArbitrary()
- ) {
- if (
- this.getMaxValue() !== null &&
- value > this.getMaxValue()
- ) {
- // Change the value to the actual max value
- return String( this.getMaxValue() );
- } else if (
- this.getMinValue() !== null &&
- value < this.getMinValue()
- ) {
- // Change the value to the actual min value
- return String( this.getMinValue() );
- }
- }
-
- return value;
- };
+ // Translate the parameter values into a filter selection state
+ this.getItems().forEach( function ( filterItem ) {
+ // All true (either because all values are written or the term 'all' is written)
+ // is the same as all filters set to true
+ result[ filterItem.getName() ] = (
+ // If it is the word 'all'
+ paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
+ // All values are written
+ paramValues.length === model.getItemCount()
+ ) ?
+ true :
+ // Otherwise, the filter is selected only if it appears in the parameter values
+ paramValues.indexOf( filterItem.getParamName() ) > -1;
+ } );
+ } else if ( this.getType() === 'single_option' ) {
+ // There is parameter that fits a single filter and if not, get the default
+ this.getItems().forEach( function ( filterItem ) {
+ var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
- /**
- * Toggle the visibility of this group
- *
- * @param {boolean} [isVisible] Item is visible
- */
- FilterGroup.prototype.toggleVisible = function ( isVisible ) {
- isVisible = isVisible === undefined ? !this.visible : isVisible;
+ result[ filterItem.getName() ] = selected;
+ oneWasSelected = oneWasSelected || selected;
+ } );
+ }
- if ( this.visible !== isVisible ) {
- this.visible = isVisible;
- this.emit( 'update' );
+ // Go over result and make sure all filters are represented.
+ // If any filters are missing, they will get a falsey value
+ this.getItems().forEach( function ( filterItem ) {
+ if ( result[ filterItem.getName() ] === undefined ) {
+ result[ filterItem.getName() ] = this.getFalsyValue();
+ }
+ }.bind( this ) );
+
+ // Make sure that at least one option is selected in
+ // single_option groups, no matter what path was taken
+ // If none was selected by the given definition, then
+ // we need to select the one in the base state -- either
+ // the default given, or the first item
+ if (
+ this.getType() === 'single_option' &&
+ !oneWasSelected
+ ) {
+ item = this.getItems()[ 0 ];
+ if ( defaultParams[ this.getName() ] ) {
+ item = this.getItemByParamName( defaultParams[ this.getName() ] );
}
- };
-
- /**
- * Check whether the group is visible
- *
- * @return {boolean} Group is visible
- */
- FilterGroup.prototype.isVisible = function () {
- return this.visible;
- };
-
- /**
- * Set the visibility of the items under this group by the given items array
- *
- * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
- */
- FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
- this.getItems().forEach( function ( itemModel ) {
- itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
- } );
- };
- module.exports = FilterGroup;
-}() );
+ result[ item.getName() ] = true;
+ }
+
+ return result;
+};
+
+/**
+ * @return {*} The appropriate falsy value for this group type
+ */
+FilterGroup.prototype.getFalsyValue = function () {
+ return this.getType() === 'any_value' ? '' : false;
+};
+
+/**
+ * Get current selected state of all filter items in this group
+ *
+ * @return {Object} Selected state
+ */
+FilterGroup.prototype.getSelectedState = function () {
+ var state = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ state[ filterItem.getName() ] = filterItem.getValue();
+ } );
+
+ return state;
+};
+
+/**
+ * Get item by its filter name
+ *
+ * @param {string} filterName Filter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+FilterGroup.prototype.getItemByName = function ( filterName ) {
+ return this.getItems().filter( function ( item ) {
+ return item.getName() === filterName;
+ } )[ 0 ];
+};
+
+/**
+ * Select an item by its parameter name
+ *
+ * @param {string} paramName Filter parameter name
+ */
+FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
+ this.getItems().forEach( function ( item ) {
+ item.toggleSelected( item.getParamName() === String( paramName ) );
+ } );
+};
+
+/**
+ * Get item by its parameter name
+ *
+ * @param {string} paramName Parameter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+FilterGroup.prototype.getItemByParamName = function ( paramName ) {
+ return this.getItems().filter( function ( item ) {
+ return item.getParamName() === String( paramName );
+ } )[ 0 ];
+};
+
+/**
+ * Get group type
+ *
+ * @return {string} Group type
+ */
+FilterGroup.prototype.getType = function () {
+ return this.type;
+};
+
+/**
+ * Check whether this group is represented by a single parameter
+ * or whether each item is its own parameter
+ *
+ * @return {boolean} This group is a single parameter
+ */
+FilterGroup.prototype.isPerGroupRequestParameter = function () {
+ return (
+ this.getType() === 'string_options' ||
+ this.getType() === 'single_option'
+ );
+};
+
+/**
+ * Get display group
+ *
+ * @return {string} Display group
+ */
+FilterGroup.prototype.getView = function () {
+ return this.view;
+};
+
+/**
+ * Get the prefix used for the filter names inside this group.
+ *
+ * @param {string} [name] Filter name to prefix
+ * @return {string} Group prefix
+ */
+FilterGroup.prototype.getNamePrefix = function () {
+ return this.getName() + '__';
+};
+
+/**
+ * Get a filter name with the prefix used for the filter names inside this group.
+ *
+ * @param {string} name Filter name to prefix
+ * @return {string} Group prefix
+ */
+FilterGroup.prototype.getPrefixedName = function ( name ) {
+ return this.getNamePrefix() + name;
+};
+
+/**
+ * Get group's title
+ *
+ * @return {string} Title
+ */
+FilterGroup.prototype.getTitle = function () {
+ return this.title;
+};
+
+/**
+ * Get group's values separator
+ *
+ * @return {string} Values separator
+ */
+FilterGroup.prototype.getSeparator = function () {
+ return this.separator;
+};
+
+/**
+ * Check whether the group is defined as full coverage
+ *
+ * @return {boolean} Group is full coverage
+ */
+FilterGroup.prototype.isFullCoverage = function () {
+ return this.fullCoverage;
+};
+
+/**
+ * Check whether the group is defined as sticky default
+ *
+ * @return {boolean} Group is sticky default
+ */
+FilterGroup.prototype.isSticky = function () {
+ return this.sticky;
+};
+
+/**
+ * Normalize a value given to this group. This is mostly for correcting
+ * arbitrary values for 'single option' groups, given by the user settings
+ * or the URL that can go outside the limits that are allowed.
+ *
+ * @param {string} value Given value
+ * @return {string} Corrected value
+ */
+FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
+ if (
+ this.getType() === 'single_option' &&
+ this.isAllowArbitrary()
+ ) {
+ if (
+ this.getMaxValue() !== null &&
+ value > this.getMaxValue()
+ ) {
+ // Change the value to the actual max value
+ return String( this.getMaxValue() );
+ } else if (
+ this.getMinValue() !== null &&
+ value < this.getMinValue()
+ ) {
+ // Change the value to the actual min value
+ return String( this.getMinValue() );
+ }
+ }
+
+ return value;
+};
+
+/**
+ * Toggle the visibility of this group
+ *
+ * @param {boolean} [isVisible] Item is visible
+ */
+FilterGroup.prototype.toggleVisible = function ( isVisible ) {
+ isVisible = isVisible === undefined ? !this.visible : isVisible;
+
+ if ( this.visible !== isVisible ) {
+ this.visible = isVisible;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Check whether the group is visible
+ *
+ * @return {boolean} Group is visible
+ */
+FilterGroup.prototype.isVisible = function () {
+ return this.visible;
+};
+
+/**
+ * Set the visibility of the items under this group by the given items array
+ *
+ * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
+ */
+FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
+ this.getItems().forEach( function ( itemModel ) {
+ itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
+ } );
+};
+
+module.exports = FilterGroup;
-( function () {
- var ItemModel = require( './ItemModel.js' ),
- FilterItem;
-
- /**
- * Filter item model
- *
- * @class mw.rcfilters.dm.FilterItem
- * @extends mw.rcfilters.dm.ItemModel
- *
- * @constructor
- * @param {string} param Filter param name
- * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
- * @param {Object} config Configuration object
- * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
- * selected, makes inactive.
- * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
- * @cfg {Object} [conflicts] Defines the conflicts for this filter
- * @cfg {boolean} [visible=true] The visibility of the group
- */
- FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
- config = config || {};
-
- this.groupModel = groupModel;
-
- // Parent
- FilterItem.parent.call( this, param, $.extend( {
- namePrefix: this.groupModel.getNamePrefix()
- }, config ) );
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- // Interaction definitions
- this.subset = config.subset || [];
- this.conflicts = config.conflicts || {};
- this.superset = [];
- this.visible = config.visible === undefined ? true : !!config.visible;
-
- // Interaction states
- this.included = false;
- this.conflicted = false;
- this.fullyCovered = false;
+var ItemModel = require( './ItemModel.js' ),
+ FilterItem;
+
+/**
+ * Filter item model
+ *
+ * @class mw.rcfilters.dm.FilterItem
+ * @extends mw.rcfilters.dm.ItemModel
+ *
+ * @constructor
+ * @param {string} param Filter param name
+ * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
+ * @param {Object} config Configuration object
+ * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
+ * selected, makes inactive.
+ * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter
+ * @cfg {boolean} [visible=true] The visibility of the group
+ */
+FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
+ config = config || {};
+
+ this.groupModel = groupModel;
+
+ // Parent
+ FilterItem.parent.call( this, param, $.extend( {
+ namePrefix: this.groupModel.getNamePrefix()
+ }, config ) );
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ // Interaction definitions
+ this.subset = config.subset || [];
+ this.conflicts = config.conflicts || {};
+ this.superset = [];
+ this.visible = config.visible === undefined ? true : !!config.visible;
+
+ // Interaction states
+ this.included = false;
+ this.conflicted = false;
+ this.fullyCovered = false;
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterItem, ItemModel );
+
+/* Methods */
+
+/**
+ * Return the representation of the state of this item.
+ *
+ * @return {Object} State of the object
+ */
+FilterItem.prototype.getState = function () {
+ return {
+ selected: this.isSelected(),
+ included: this.isIncluded(),
+ conflicted: this.isConflicted(),
+ fullyCovered: this.isFullyCovered()
};
-
- /* Initialization */
-
- OO.inheritClass( FilterItem, ItemModel );
-
- /* Methods */
-
- /**
- * Return the representation of the state of this item.
- *
- * @return {Object} State of the object
- */
- FilterItem.prototype.getState = function () {
- return {
- selected: this.isSelected(),
- included: this.isIncluded(),
- conflicted: this.isConflicted(),
- fullyCovered: this.isFullyCovered()
- };
- };
-
- /**
- * Get the message for the display area for the currently active conflict
- *
- * @private
- * @return {string} Conflict result message key
- */
- FilterItem.prototype.getCurrentConflictResultMessage = function () {
- var details = {};
-
- // First look in filter's own conflicts
- details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
- if ( !details.message ) {
- // Fall back onto conflicts in the group
- details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
- }
-
- return details.message;
- };
-
- /**
- * Get the details of the active conflict on this filter
- *
- * @private
- * @param {Object} conflicts Conflicts to examine
- * @param {string} [key='contextDescription'] Message key
- * @return {Object} Object with conflict message and conflict items
- * @return {string} return.message Conflict message
- * @return {string[]} return.names Conflicting item labels
- */
- FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
- var group,
- conflictMessage = '',
- itemLabels = [];
-
- key = key || 'contextDescription';
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( conflicts, function ( filterName, conflict ) {
- if ( !conflict.item.isSelected() ) {
- return;
- }
-
- if ( !conflictMessage ) {
- conflictMessage = conflict[ key ];
- group = conflict.group;
- }
-
- if ( group === conflict.group ) {
- itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
- }
- } );
-
- return {
- message: conflictMessage,
- names: itemLabels
- };
-
- };
-
- /**
- * @inheritdoc
- */
- FilterItem.prototype.getStateMessage = function () {
- var messageKey, details, superset,
- affectingItems = [];
-
- if ( this.isSelected() ) {
- if ( this.isConflicted() ) {
- // First look in filter's own conflicts
- details = this.getConflictDetails( this.getOwnConflicts() );
- if ( !details.message ) {
- // Fall back onto conflicts in the group
- details = this.getConflictDetails( this.getGroupModel().getConflicts() );
- }
-
- messageKey = details.message;
- affectingItems = details.names;
- } else if ( this.isIncluded() && !this.isHighlighted() ) {
- // We only show the 'no effect' full-coverage message
- // if the item is also not highlighted. See T161273
- superset = this.getSuperset();
- // For this message we need to collect the affecting superset
- affectingItems = this.getGroupModel().findSelectedItems( this )
- .filter( function ( item ) {
- return superset.indexOf( item.getName() ) !== -1;
- } )
- .map( function ( item ) {
- return mw.msg( 'quotation-marks', item.getLabel() );
- } );
-
- messageKey = 'rcfilters-state-message-subset';
- } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
- affectingItems = this.getGroupModel().findSelectedItems( this )
- .map( function ( item ) {
- return mw.msg( 'quotation-marks', item.getLabel() );
- } );
-
- messageKey = 'rcfilters-state-message-fullcoverage';
- }
+};
+
+/**
+ * Get the message for the display area for the currently active conflict
+ *
+ * @private
+ * @return {string} Conflict result message key
+ */
+FilterItem.prototype.getCurrentConflictResultMessage = function () {
+ var details = {};
+
+ // First look in filter's own conflicts
+ details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
+ if ( !details.message ) {
+ // Fall back onto conflicts in the group
+ details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
+ }
+
+ return details.message;
+};
+
+/**
+ * Get the details of the active conflict on this filter
+ *
+ * @private
+ * @param {Object} conflicts Conflicts to examine
+ * @param {string} [key='contextDescription'] Message key
+ * @return {Object} Object with conflict message and conflict items
+ * @return {string} return.message Conflict message
+ * @return {string[]} return.names Conflicting item labels
+ */
+FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
+ var group,
+ conflictMessage = '',
+ itemLabels = [];
+
+ key = key || 'contextDescription';
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( conflicts, function ( filterName, conflict ) {
+ if ( !conflict.item.isSelected() ) {
+ return;
}
- if ( messageKey ) {
- // Build message
- return mw.msg(
- messageKey,
- mw.language.listToText( affectingItems ),
- affectingItems.length
- );
+ if ( !conflictMessage ) {
+ conflictMessage = conflict[ key ];
+ group = conflict.group;
}
- // Display description
- return this.getDescription();
- };
-
- /**
- * Get the model of the group this filter belongs to
- *
- * @return {mw.rcfilters.dm.FilterGroup} Filter group model
- */
- FilterItem.prototype.getGroupModel = function () {
- return this.groupModel;
- };
-
- /**
- * Get the group name this filter belongs to
- *
- * @return {string} Filter group name
- */
- FilterItem.prototype.getGroupName = function () {
- return this.groupModel.getName();
- };
-
- /**
- * Get filter subset
- * This is a list of filter names that are defined to be included
- * when this filter is selected.
- *
- * @return {string[]} Filter subset
- */
- FilterItem.prototype.getSubset = function () {
- return this.subset;
- };
-
- /**
- * Get filter superset
- * This is a generated list of filters that define this filter
- * to be included when either of them is selected.
- *
- * @return {string[]} Filter superset
- */
- FilterItem.prototype.getSuperset = function () {
- return this.superset;
- };
-
- /**
- * Check whether the filter is currently in a conflict state
- *
- * @return {boolean} Filter is in conflict state
- */
- FilterItem.prototype.isConflicted = function () {
- return this.conflicted;
- };
-
- /**
- * Check whether the filter is currently in an already included subset
- *
- * @return {boolean} Filter is in an already-included subset
- */
- FilterItem.prototype.isIncluded = function () {
- return this.included;
- };
-
- /**
- * Check whether the filter is currently fully covered
- *
- * @return {boolean} Filter is in fully-covered state
- */
- FilterItem.prototype.isFullyCovered = function () {
- return this.fullyCovered;
- };
-
- /**
- * Get all conflicts associated with this filter or its group
- *
- * Conflict object is set up by filter name keys and conflict
- * definition. For example:
- *
- * {
- * filterName: {
- * filter: filterName,
- * group: group1,
- * label: itemLabel,
- * item: itemModel
- * }
- * filterName2: {
- * filter: filterName2,
- * group: group2
- * label: itemLabel2,
- * item: itemModel2
- * }
- * }
- *
- * @return {Object} Filter conflicts
- */
- FilterItem.prototype.getConflicts = function () {
- return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
- };
-
- /**
- * Get the conflicts associated with this filter
- *
- * @return {Object} Filter conflicts
- */
- FilterItem.prototype.getOwnConflicts = function () {
- return this.conflicts;
- };
-
- /**
- * Set conflicts for this filter. See #getConflicts for the expected
- * structure of the definition.
- *
- * @param {Object} conflicts Conflicts for this filter
- */
- FilterItem.prototype.setConflicts = function ( conflicts ) {
- this.conflicts = conflicts || {};
- };
-
- /**
- * Set filter superset
- *
- * @param {string[]} superset Filter superset
- */
- FilterItem.prototype.setSuperset = function ( superset ) {
- this.superset = superset || [];
- };
-
- /**
- * Set filter subset
- *
- * @param {string[]} subset Filter subset
- */
- FilterItem.prototype.setSubset = function ( subset ) {
- this.subset = subset || [];
- };
-
- /**
- * Check whether a filter exists in the subset list for this filter
- *
- * @param {string} filterName Filter name
- * @return {boolean} Filter name is in the subset list
- */
- FilterItem.prototype.existsInSubset = function ( filterName ) {
- return this.subset.indexOf( filterName ) > -1;
- };
-
- /**
- * Check whether this item has a potential conflict with the given item
- *
- * This checks whether the given item is in the list of conflicts of
- * the current item, but makes no judgment about whether the conflict
- * is currently at play (either one of the items may not be selected)
- *
- * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
- * @return {boolean} This item has a conflict with the given item
- */
- FilterItem.prototype.existsInConflicts = function ( filterItem ) {
- return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
- };
-
- /**
- * Set the state of this filter as being conflicted
- * (This means any filters in its conflicts are selected)
- *
- * @param {boolean} [conflicted] Filter is in conflict state
- * @fires update
- */
- FilterItem.prototype.toggleConflicted = function ( conflicted ) {
- conflicted = conflicted === undefined ? !this.conflicted : conflicted;
-
- if ( this.conflicted !== conflicted ) {
- this.conflicted = conflicted;
- this.emit( 'update' );
+ if ( group === conflict.group ) {
+ itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
}
- };
+ } );
- /**
- * Set the state of this filter as being already included
- * (This means any filters in its superset are selected)
- *
- * @param {boolean} [included] Filter is included as part of a subset
- * @fires update
- */
- FilterItem.prototype.toggleIncluded = function ( included ) {
- included = included === undefined ? !this.included : included;
-
- if ( this.included !== included ) {
- this.included = included;
- this.emit( 'update' );
- }
+ return {
+ message: conflictMessage,
+ names: itemLabels
};
- /**
- * Toggle the fully covered state of the item
- *
- * @param {boolean} [isFullyCovered] Filter is fully covered
- * @fires update
- */
- FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
- isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
-
- if ( this.fullyCovered !== isFullyCovered ) {
- this.fullyCovered = isFullyCovered;
- this.emit( 'update' );
- }
- };
+};
+
+/**
+ * @inheritdoc
+ */
+FilterItem.prototype.getStateMessage = function () {
+ var messageKey, details, superset,
+ affectingItems = [];
+
+ if ( this.isSelected() ) {
+ if ( this.isConflicted() ) {
+ // First look in filter's own conflicts
+ details = this.getConflictDetails( this.getOwnConflicts() );
+ if ( !details.message ) {
+ // Fall back onto conflicts in the group
+ details = this.getConflictDetails( this.getGroupModel().getConflicts() );
+ }
- /**
- * Toggle the visibility of this item
- *
- * @param {boolean} [isVisible] Item is visible
- */
- FilterItem.prototype.toggleVisible = function ( isVisible ) {
- isVisible = isVisible === undefined ? !this.visible : !!isVisible;
-
- if ( this.visible !== isVisible ) {
- this.visible = isVisible;
- this.emit( 'update' );
+ messageKey = details.message;
+ affectingItems = details.names;
+ } else if ( this.isIncluded() && !this.isHighlighted() ) {
+ // We only show the 'no effect' full-coverage message
+ // if the item is also not highlighted. See T161273
+ superset = this.getSuperset();
+ // For this message we need to collect the affecting superset
+ affectingItems = this.getGroupModel().findSelectedItems( this )
+ .filter( function ( item ) {
+ return superset.indexOf( item.getName() ) !== -1;
+ } )
+ .map( function ( item ) {
+ return mw.msg( 'quotation-marks', item.getLabel() );
+ } );
+
+ messageKey = 'rcfilters-state-message-subset';
+ } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
+ affectingItems = this.getGroupModel().findSelectedItems( this )
+ .map( function ( item ) {
+ return mw.msg( 'quotation-marks', item.getLabel() );
+ } );
+
+ messageKey = 'rcfilters-state-message-fullcoverage';
}
- };
-
- /**
- * Check whether the item is visible
- *
- * @return {boolean} Item is visible
- */
- FilterItem.prototype.isVisible = function () {
- return this.visible;
- };
-
- module.exports = FilterItem;
-
-}() );
+ }
+
+ if ( messageKey ) {
+ // Build message
+ return mw.msg(
+ messageKey,
+ mw.language.listToText( affectingItems ),
+ affectingItems.length
+ );
+ }
+
+ // Display description
+ return this.getDescription();
+};
+
+/**
+ * Get the model of the group this filter belongs to
+ *
+ * @return {mw.rcfilters.dm.FilterGroup} Filter group model
+ */
+FilterItem.prototype.getGroupModel = function () {
+ return this.groupModel;
+};
+
+/**
+ * Get the group name this filter belongs to
+ *
+ * @return {string} Filter group name
+ */
+FilterItem.prototype.getGroupName = function () {
+ return this.groupModel.getName();
+};
+
+/**
+ * Get filter subset
+ * This is a list of filter names that are defined to be included
+ * when this filter is selected.
+ *
+ * @return {string[]} Filter subset
+ */
+FilterItem.prototype.getSubset = function () {
+ return this.subset;
+};
+
+/**
+ * Get filter superset
+ * This is a generated list of filters that define this filter
+ * to be included when either of them is selected.
+ *
+ * @return {string[]} Filter superset
+ */
+FilterItem.prototype.getSuperset = function () {
+ return this.superset;
+};
+
+/**
+ * Check whether the filter is currently in a conflict state
+ *
+ * @return {boolean} Filter is in conflict state
+ */
+FilterItem.prototype.isConflicted = function () {
+ return this.conflicted;
+};
+
+/**
+ * Check whether the filter is currently in an already included subset
+ *
+ * @return {boolean} Filter is in an already-included subset
+ */
+FilterItem.prototype.isIncluded = function () {
+ return this.included;
+};
+
+/**
+ * Check whether the filter is currently fully covered
+ *
+ * @return {boolean} Filter is in fully-covered state
+ */
+FilterItem.prototype.isFullyCovered = function () {
+ return this.fullyCovered;
+};
+
+/**
+ * Get all conflicts associated with this filter or its group
+ *
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ *
+ * {
+ * filterName: {
+ * filter: filterName,
+ * group: group1,
+ * label: itemLabel,
+ * item: itemModel
+ * }
+ * filterName2: {
+ * filter: filterName2,
+ * group: group2
+ * label: itemLabel2,
+ * item: itemModel2
+ * }
+ * }
+ *
+ * @return {Object} Filter conflicts
+ */
+FilterItem.prototype.getConflicts = function () {
+ return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
+};
+
+/**
+ * Get the conflicts associated with this filter
+ *
+ * @return {Object} Filter conflicts
+ */
+FilterItem.prototype.getOwnConflicts = function () {
+ return this.conflicts;
+};
+
+/**
+ * Set conflicts for this filter. See #getConflicts for the expected
+ * structure of the definition.
+ *
+ * @param {Object} conflicts Conflicts for this filter
+ */
+FilterItem.prototype.setConflicts = function ( conflicts ) {
+ this.conflicts = conflicts || {};
+};
+
+/**
+ * Set filter superset
+ *
+ * @param {string[]} superset Filter superset
+ */
+FilterItem.prototype.setSuperset = function ( superset ) {
+ this.superset = superset || [];
+};
+
+/**
+ * Set filter subset
+ *
+ * @param {string[]} subset Filter subset
+ */
+FilterItem.prototype.setSubset = function ( subset ) {
+ this.subset = subset || [];
+};
+
+/**
+ * Check whether a filter exists in the subset list for this filter
+ *
+ * @param {string} filterName Filter name
+ * @return {boolean} Filter name is in the subset list
+ */
+FilterItem.prototype.existsInSubset = function ( filterName ) {
+ return this.subset.indexOf( filterName ) > -1;
+};
+
+/**
+ * Check whether this item has a potential conflict with the given item
+ *
+ * This checks whether the given item is in the list of conflicts of
+ * the current item, but makes no judgment about whether the conflict
+ * is currently at play (either one of the items may not be selected)
+ *
+ * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+ * @return {boolean} This item has a conflict with the given item
+ */
+FilterItem.prototype.existsInConflicts = function ( filterItem ) {
+ return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+};
+
+/**
+ * Set the state of this filter as being conflicted
+ * (This means any filters in its conflicts are selected)
+ *
+ * @param {boolean} [conflicted] Filter is in conflict state
+ * @fires update
+ */
+FilterItem.prototype.toggleConflicted = function ( conflicted ) {
+ conflicted = conflicted === undefined ? !this.conflicted : conflicted;
+
+ if ( this.conflicted !== conflicted ) {
+ this.conflicted = conflicted;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Set the state of this filter as being already included
+ * (This means any filters in its superset are selected)
+ *
+ * @param {boolean} [included] Filter is included as part of a subset
+ * @fires update
+ */
+FilterItem.prototype.toggleIncluded = function ( included ) {
+ included = included === undefined ? !this.included : included;
+
+ if ( this.included !== included ) {
+ this.included = included;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Toggle the fully covered state of the item
+ *
+ * @param {boolean} [isFullyCovered] Filter is fully covered
+ * @fires update
+ */
+FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
+ isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
+
+ if ( this.fullyCovered !== isFullyCovered ) {
+ this.fullyCovered = isFullyCovered;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Toggle the visibility of this item
+ *
+ * @param {boolean} [isVisible] Item is visible
+ */
+FilterItem.prototype.toggleVisible = function ( isVisible ) {
+ isVisible = isVisible === undefined ? !this.visible : !!isVisible;
+
+ if ( this.visible !== isVisible ) {
+ this.visible = isVisible;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Check whether the item is visible
+ *
+ * @return {boolean} Item is visible
+ */
+FilterItem.prototype.isVisible = function () {
+ return this.visible;
+};
+
+module.exports = FilterItem;
-( function () {
- var FilterGroup = require( './FilterGroup.js' ),
- FilterItem = require( './FilterItem.js' ),
- FiltersViewModel;
-
- /**
- * View model for the filters selection and display
- *
- * @class mw.rcfilters.dm.FiltersViewModel
- * @mixins OO.EventEmitter
- * @mixins OO.EmitterList
- *
- * @constructor
- */
- FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
- // Mixin constructor
- OO.EventEmitter.call( this );
- OO.EmitterList.call( this );
-
- this.groups = {};
- this.defaultParams = {};
- this.highlightEnabled = false;
- this.parameterMap = {};
- this.emptyParameterState = null;
-
- this.views = {};
- this.currentView = 'default';
- this.searchQuery = null;
-
- // Events
- this.aggregate( { update: 'filterItemUpdate' } );
- this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
- };
-
- /* Initialization */
- OO.initClass( FiltersViewModel );
- OO.mixinClass( FiltersViewModel, OO.EventEmitter );
- OO.mixinClass( FiltersViewModel, OO.EmitterList );
-
- /* Events */
-
- /**
- * @event initialize
- *
- * Filter list is initialized
- */
-
- /**
- * @event update
- *
- * Model has been updated
- */
-
- /**
- * @event itemUpdate
- * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
- *
- * Filter item has changed
- */
-
- /**
- * @event highlightChange
- * @param {boolean} Highlight feature is enabled
- *
- * Highlight feature has been toggled enabled or disabled
- */
-
- /* Methods */
-
- /**
- * Re-assess the states of filter items based on the interactions between them
- *
- * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
- * method will go over the state of all items
- */
- FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
- var allSelected,
- model = this,
- iterationItems = item !== undefined ? [ item ] : this.getItems();
-
- iterationItems.forEach( function ( checkedItem ) {
- var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
- groupModel = checkedItem.getGroupModel();
-
- // Check for subsets (included filters) plus the item itself:
- allCheckedItems.forEach( function ( filterItemName ) {
- var itemInSubset = model.getItemByName( filterItemName );
-
- itemInSubset.toggleIncluded(
- // If any of itemInSubset's supersets are selected, this item
- // is included
- itemInSubset.getSuperset().some( function ( supersetName ) {
- return ( model.getItemByName( supersetName ).isSelected() );
+var FilterGroup = require( './FilterGroup.js' ),
+ FilterItem = require( './FilterItem.js' ),
+ FiltersViewModel;
+
+/**
+ * View model for the filters selection and display
+ *
+ * @class mw.rcfilters.dm.FiltersViewModel
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ */
+FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+ OO.EmitterList.call( this );
+
+ this.groups = {};
+ this.defaultParams = {};
+ this.highlightEnabled = false;
+ this.parameterMap = {};
+ this.emptyParameterState = null;
+
+ this.views = {};
+ this.currentView = 'default';
+ this.searchQuery = null;
+
+ // Events
+ this.aggregate( { update: 'filterItemUpdate' } );
+ this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
+};
+
+/* Initialization */
+OO.initClass( FiltersViewModel );
+OO.mixinClass( FiltersViewModel, OO.EventEmitter );
+OO.mixinClass( FiltersViewModel, OO.EmitterList );
+
+/* Events */
+
+/**
+ * @event initialize
+ *
+ * Filter list is initialized
+ */
+
+/**
+ * @event update
+ *
+ * Model has been updated
+ */
+
+/**
+ * @event itemUpdate
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
+ *
+ * Filter item has changed
+ */
+
+/**
+ * @event highlightChange
+ * @param {boolean} Highlight feature is enabled
+ *
+ * Highlight feature has been toggled enabled or disabled
+ */
+
+/* Methods */
+
+/**
+ * Re-assess the states of filter items based on the interactions between them
+ *
+ * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
+ * method will go over the state of all items
+ */
+FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+ var allSelected,
+ model = this,
+ iterationItems = item !== undefined ? [ item ] : this.getItems();
+
+ iterationItems.forEach( function ( checkedItem ) {
+ var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+ groupModel = checkedItem.getGroupModel();
+
+ // Check for subsets (included filters) plus the item itself:
+ allCheckedItems.forEach( function ( filterItemName ) {
+ var itemInSubset = model.getItemByName( filterItemName );
+
+ itemInSubset.toggleIncluded(
+ // If any of itemInSubset's supersets are selected, this item
+ // is included
+ itemInSubset.getSuperset().some( function ( supersetName ) {
+ return ( model.getItemByName( supersetName ).isSelected() );
+ } )
+ );
+ } );
+
+ // Update coverage for the changed group
+ if ( groupModel.isFullCoverage() ) {
+ allSelected = groupModel.areAllSelected();
+ groupModel.getItems().forEach( function ( filterItem ) {
+ filterItem.toggleFullyCovered( allSelected );
+ } );
+ }
+ } );
+
+ // Check for conflicts
+ // In this case, we must go over all items, since
+ // conflicts are bidirectional and depend not only on
+ // individual items, but also on the selected states of
+ // the groups they're in.
+ this.getItems().forEach( function ( filterItem ) {
+ var inConflict = false,
+ filterItemGroup = filterItem.getGroupModel();
+
+ // For each item, see if that item is still conflicting
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( model.groups, function ( groupName, groupModel ) {
+ if ( filterItem.getGroupName() === groupName ) {
+ // Check inside the group
+ inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+ } else {
+ // According to the spec, if two items conflict from two different
+ // groups, the conflict only lasts if the groups **only have selected
+ // items that are conflicting**. If a group has selected items that
+ // are conflicting and non-conflicting, the scope of the result has
+ // expanded enough to completely remove the conflict.
+
+ // For example, see two groups with conflicts:
+ // userExpLevel: [
+ // {
+ // name: 'experienced',
+ // conflicts: [ 'unregistered' ]
+ // }
+ // ],
+ // registration: [
+ // {
+ // name: 'registered',
+ // },
+ // {
+ // name: 'unregistered',
+ // }
+ // ]
+ // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+ // because, inherently, 'experienced' filter only includes registered users, and so
+ // both filters are in conflict with one another.
+ // However, the minute we select 'registered', the scope of our results
+ // has expanded to no longer have a conflict with 'experienced' filter, and
+ // so the conflict is removed.
+
+ // In our case, we need to check if the entire group conflicts with
+ // the entire item's group, so we follow the above spec
+ inConflict = (
+ // The foreign group is in conflict with this item
+ groupModel.areAllSelectedInConflictWith( filterItem ) &&
+ // Every selected member of the item's own group is also
+ // in conflict with the other group
+ filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
+ return groupModel.areAllSelectedInConflictWith( otherGroupItem );
} )
);
- } );
-
- // Update coverage for the changed group
- if ( groupModel.isFullCoverage() ) {
- allSelected = groupModel.areAllSelected();
- groupModel.getItems().forEach( function ( filterItem ) {
- filterItem.toggleFullyCovered( allSelected );
- } );
}
+
+ // If we're in conflict, this will return 'false' which
+ // will break the loop. Otherwise, we're not in conflict
+ // and the loop continues
+ return !inConflict;
} );
- // Check for conflicts
- // In this case, we must go over all items, since
- // conflicts are bidirectional and depend not only on
- // individual items, but also on the selected states of
- // the groups they're in.
- this.getItems().forEach( function ( filterItem ) {
- var inConflict = false,
- filterItemGroup = filterItem.getGroupModel();
+ // Toggle the item state
+ filterItem.toggleConflicted( inConflict );
+ } );
+};
+
+/**
+ * Get whether the model has any conflict in its items
+ *
+ * @return {boolean} There is a conflict
+ */
+FiltersViewModel.prototype.hasConflict = function () {
+ return this.getItems().some( function ( filterItem ) {
+ return filterItem.isSelected() && filterItem.isConflicted();
+ } );
+};
+
+/**
+ * Get the first item with a current conflict
+ *
+ * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
+ */
+FiltersViewModel.prototype.getFirstConflictedItem = function () {
+ var i, filterItem, items = this.getItems();
+ for ( i = 0; i < items.length; i++ ) {
+ filterItem = items[ i ];
+ if ( filterItem.isSelected() && filterItem.isConflicted() ) {
+ return filterItem;
+ }
+ }
+};
+
+/**
+ * Set filters and preserve a group relationship based on
+ * the definition given by an object
+ *
+ * @param {Array} filterGroups Filters definition
+ * @param {Object} [views] Extra views definition
+ * Expected in the following format:
+ * {
+ * namespaces: {
+ * label: 'namespaces', // Message key
+ * trigger: ':',
+ * groups: [
+ * {
+ * // Group info
+ * name: 'namespaces' // Parameter name
+ * title: 'namespaces' // Message key
+ * type: 'string_options',
+ * separator: ';',
+ * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+ * fullCoverage: true
+ * items: []
+ * }
+ * ]
+ * }
+ * }
+ */
+FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
+ var filterConflictResult, groupConflictResult,
+ allViews = {},
+ model = this,
+ items = [],
+ groupConflictMap = {},
+ filterConflictMap = {},
+ /*!
+ * Expand a conflict definition from group name to
+ * the list of all included filters in that group.
+ * We do this so that the direct relationship in the
+ * models are consistently item->items rather than
+ * mixing item->group with item->item.
+ *
+ * @param {Object} obj Conflict definition
+ * @return {Object} Expanded conflict definition
+ */
+ expandConflictDefinitions = function ( obj ) {
+ var result = {};
- // For each item, see if that item is still conflicting
// eslint-disable-next-line no-jquery/no-each-util
- $.each( model.groups, function ( groupName, groupModel ) {
- if ( filterItem.getGroupName() === groupName ) {
- // Check inside the group
- inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
- } else {
- // According to the spec, if two items conflict from two different
- // groups, the conflict only lasts if the groups **only have selected
- // items that are conflicting**. If a group has selected items that
- // are conflicting and non-conflicting, the scope of the result has
- // expanded enough to completely remove the conflict.
-
- // For example, see two groups with conflicts:
- // userExpLevel: [
- // {
- // name: 'experienced',
- // conflicts: [ 'unregistered' ]
- // }
- // ],
- // registration: [
- // {
- // name: 'registered',
- // },
- // {
- // name: 'unregistered',
- // }
- // ]
- // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
- // because, inherently, 'experienced' filter only includes registered users, and so
- // both filters are in conflict with one another.
- // However, the minute we select 'registered', the scope of our results
- // has expanded to no longer have a conflict with 'experienced' filter, and
- // so the conflict is removed.
-
- // In our case, we need to check if the entire group conflicts with
- // the entire item's group, so we follow the above spec
- inConflict = (
- // The foreign group is in conflict with this item
- groupModel.areAllSelectedInConflictWith( filterItem ) &&
- // Every selected member of the item's own group is also
- // in conflict with the other group
- filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
- return groupModel.areAllSelectedInConflictWith( otherGroupItem );
- } )
- );
- }
-
- // If we're in conflict, this will return 'false' which
- // will break the loop. Otherwise, we're not in conflict
- // and the loop continues
- return !inConflict;
- } );
-
- // Toggle the item state
- filterItem.toggleConflicted( inConflict );
- } );
- };
-
- /**
- * Get whether the model has any conflict in its items
- *
- * @return {boolean} There is a conflict
- */
- FiltersViewModel.prototype.hasConflict = function () {
- return this.getItems().some( function ( filterItem ) {
- return filterItem.isSelected() && filterItem.isConflicted();
- } );
- };
-
- /**
- * Get the first item with a current conflict
- *
- * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
- */
- FiltersViewModel.prototype.getFirstConflictedItem = function () {
- var i, filterItem, items = this.getItems();
- for ( i = 0; i < items.length; i++ ) {
- filterItem = items[ i ];
- if ( filterItem.isSelected() && filterItem.isConflicted() ) {
- return filterItem;
- }
- }
- };
-
- /**
- * Set filters and preserve a group relationship based on
- * the definition given by an object
- *
- * @param {Array} filterGroups Filters definition
- * @param {Object} [views] Extra views definition
- * Expected in the following format:
- * {
- * namespaces: {
- * label: 'namespaces', // Message key
- * trigger: ':',
- * groups: [
- * {
- * // Group info
- * name: 'namespaces' // Parameter name
- * title: 'namespaces' // Message key
- * type: 'string_options',
- * separator: ';',
- * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
- * fullCoverage: true
- * items: []
- * }
- * ]
- * }
- * }
- */
- FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
- var filterConflictResult, groupConflictResult,
- allViews = {},
- model = this,
- items = [],
- groupConflictMap = {},
- filterConflictMap = {},
- /*!
- * Expand a conflict definition from group name to
- * the list of all included filters in that group.
- * We do this so that the direct relationship in the
- * models are consistently item->items rather than
- * mixing item->group with item->item.
- *
- * @param {Object} obj Conflict definition
- * @return {Object} Expanded conflict definition
- */
- expandConflictDefinitions = function ( obj ) {
- var result = {};
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( obj, function ( key, conflicts ) {
- var filterName,
- adjustedConflicts = {};
-
- conflicts.forEach( function ( conflict ) {
- var filter;
-
- if ( conflict.filter ) {
- filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
- filter = model.getItemByName( filterName );
-
- // Rename
- adjustedConflicts[ filterName ] = $.extend(
+ $.each( obj, function ( key, conflicts ) {
+ var filterName,
+ adjustedConflicts = {};
+
+ conflicts.forEach( function ( conflict ) {
+ var filter;
+
+ if ( conflict.filter ) {
+ filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
+ filter = model.getItemByName( filterName );
+
+ // Rename
+ adjustedConflicts[ filterName ] = $.extend(
+ {},
+ conflict,
+ {
+ filter: filterName,
+ item: filter
+ }
+ );
+ } else {
+ // This conflict is for an entire group. Split it up to
+ // represent each filter
+
+ // Get the relevant group items
+ model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
+ // Rebuild the conflict
+ adjustedConflicts[ groupItem.getName() ] = $.extend(
{},
conflict,
{
- filter: filterName,
- item: filter
+ filter: groupItem.getName(),
+ item: groupItem
}
);
- } else {
- // This conflict is for an entire group. Split it up to
- // represent each filter
-
- // Get the relevant group items
- model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
- // Rebuild the conflict
- adjustedConflicts[ groupItem.getName() ] = $.extend(
- {},
- conflict,
- {
- filter: groupItem.getName(),
- item: groupItem
- }
- );
- } );
- }
- } );
-
- result[ key ] = adjustedConflicts;
- } );
-
- return result;
- };
-
- // Reset
- this.clearItems();
- this.groups = {};
- this.views = {};
-
- // Clone
- filterGroups = OO.copy( filterGroups );
-
- // Normalize definition from the server
- filterGroups.forEach( function ( data ) {
- var i;
- // What's this information needs to be normalized
- data.whatsThis = {
- body: data.whatsThisBody,
- header: data.whatsThisHeader,
- linkText: data.whatsThisLinkText,
- url: data.whatsThisUrl
- };
-
- // Title is a msg-key
- data.title = data.title ? mw.msg( data.title ) : data.name;
-
- // Filters are given to us with msg-keys, we need
- // to translate those before we hand them off
- for ( i = 0; i < data.filters.length; i++ ) {
- data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
- data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
- }
- } );
-
- // Collect views
- allViews = $.extend( true, {
- default: {
- title: mw.msg( 'rcfilters-filterlist-title' ),
- groups: filterGroups
- }
- }, views );
-
- // Go over all views
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( allViews, function ( viewName, viewData ) {
- // Define the view
- model.views[ viewName ] = {
- name: viewData.name,
- title: viewData.title,
- trigger: viewData.trigger
- };
-
- // Go over groups
- viewData.groups.forEach( function ( groupData ) {
- var group = groupData.name;
-
- if ( !model.groups[ group ] ) {
- model.groups[ group ] = new FilterGroup(
- group,
- $.extend( true, {}, groupData, { view: viewName } )
- );
- }
-
- model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
- items = items.concat( model.groups[ group ].getItems() );
-
- // Prepare conflicts
- if ( groupData.conflicts ) {
- // Group conflicts
- groupConflictMap[ group ] = groupData.conflicts;
- }
-
- groupData.filters.forEach( function ( itemData ) {
- var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
- // Filter conflicts
- if ( itemData.conflicts ) {
- filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+ } );
}
} );
- } );
- } );
-
- // Add item references to the model, for lookup
- this.addItems( items );
- // Expand conflicts
- groupConflictResult = expandConflictDefinitions( groupConflictMap );
- filterConflictResult = expandConflictDefinitions( filterConflictMap );
-
- // Set conflicts for groups
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( groupConflictResult, function ( group, conflicts ) {
- model.groups[ group ].setConflicts( conflicts );
- } );
+ result[ key ] = adjustedConflicts;
+ } );
- // Set conflicts for items
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( filterConflictResult, function ( filterName, conflicts ) {
- var filterItem = model.getItemByName( filterName );
- // set conflicts for items in the group
- filterItem.setConflicts( conflicts );
- } );
+ return result;
+ };
+
+ // Reset
+ this.clearItems();
+ this.groups = {};
+ this.views = {};
+
+ // Clone
+ filterGroups = OO.copy( filterGroups );
+
+ // Normalize definition from the server
+ filterGroups.forEach( function ( data ) {
+ var i;
+ // What's this information needs to be normalized
+ data.whatsThis = {
+ body: data.whatsThisBody,
+ header: data.whatsThisHeader,
+ linkText: data.whatsThisLinkText,
+ url: data.whatsThisUrl
+ };
+
+ // Title is a msg-key
+ data.title = data.title ? mw.msg( data.title ) : data.name;
+
+ // Filters are given to us with msg-keys, we need
+ // to translate those before we hand them off
+ for ( i = 0; i < data.filters.length; i++ ) {
+ data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
+ data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
+ }
+ } );
- // Create a map between known parameters and their models
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.groups, function ( group, groupModel ) {
- if (
- groupModel.getType() === 'send_unselected_if_any' ||
- groupModel.getType() === 'boolean' ||
- groupModel.getType() === 'any_value'
- ) {
- // Individual filters
- groupModel.getItems().forEach( function ( filterItem ) {
- model.parameterMap[ filterItem.getParamName() ] = filterItem;
- } );
- } else if (
- groupModel.getType() === 'string_options' ||
- groupModel.getType() === 'single_option'
- ) {
- // Group
- model.parameterMap[ groupModel.getName() ] = groupModel;
+ // Collect views
+ allViews = $.extend( true, {
+ default: {
+ title: mw.msg( 'rcfilters-filterlist-title' ),
+ groups: filterGroups
+ }
+ }, views );
+
+ // Go over all views
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( allViews, function ( viewName, viewData ) {
+ // Define the view
+ model.views[ viewName ] = {
+ name: viewData.name,
+ title: viewData.title,
+ trigger: viewData.trigger
+ };
+
+ // Go over groups
+ viewData.groups.forEach( function ( groupData ) {
+ var group = groupData.name;
+
+ if ( !model.groups[ group ] ) {
+ model.groups[ group ] = new FilterGroup(
+ group,
+ $.extend( true, {}, groupData, { view: viewName } )
+ );
}
- } );
-
- this.setSearch( '' );
-
- this.updateHighlightedState();
- // Finish initialization
- this.emit( 'initialize' );
- };
+ model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+ items = items.concat( model.groups[ group ].getItems() );
- /**
- * Update filter view model state based on a parameter object
- *
- * @param {Object} params Parameters object
- */
- FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
- var filtersValue;
- // For arbitrary numeric single_option values make sure the values
- // are normalized to fit within the limits
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
- params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
- } );
-
- // Update filter values
- filtersValue = this.getFiltersFromParameters( params );
- Object.keys( filtersValue ).forEach( function ( filterName ) {
- this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
- }.bind( this ) );
-
- // Update highlight state
- this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
- var color = params[ filterItem.getName() + '_color' ];
- if ( color ) {
- filterItem.setHighlightColor( color );
- } else {
- filterItem.clearHighlightColor();
+ // Prepare conflicts
+ if ( groupData.conflicts ) {
+ // Group conflicts
+ groupConflictMap[ group ] = groupData.conflicts;
}
- } );
- this.updateHighlightedState();
-
- // Check all filter interactions
- this.reassessFilterInteractions();
- };
-
- /**
- * Get a representation of an empty (falsey) parameter state
- *
- * @return {Object} Empty parameter state
- */
- FiltersViewModel.prototype.getEmptyParameterState = function () {
- if ( !this.emptyParameterState ) {
- this.emptyParameterState = $.extend(
- true,
- {},
- this.getParametersFromFilters( {} ),
- this.getEmptyHighlightParameters()
- );
- }
- return this.emptyParameterState;
- };
-
- /**
- * Get a representation of only the non-falsey parameters
- *
- * @param {Object} [parameters] A given parameter state to minimize. If not given the current
- * state of the system will be used.
- * @return {Object} Empty parameter state
- */
- FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
- var result = {};
-
- parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
-
- // Params
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.getEmptyParameterState(), function ( param, value ) {
- if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
- result[ param ] = parameters[ param ];
- }
- } );
- // Highlights
- Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
- if ( parameters[ param ] ) {
- // If a highlight parameter is not undefined and not null
- // add it to the result
- result[ param ] = parameters[ param ];
- }
+ groupData.filters.forEach( function ( itemData ) {
+ var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+ // Filter conflicts
+ if ( itemData.conflicts ) {
+ filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+ }
+ } );
} );
-
- return result;
- };
-
- /**
- * Get a representation of the full parameter list, including all base values
- *
- * @return {Object} Full parameter representation
- */
- FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
- return $.extend(
+ } );
+
+ // Add item references to the model, for lookup
+ this.addItems( items );
+
+ // Expand conflicts
+ groupConflictResult = expandConflictDefinitions( groupConflictMap );
+ filterConflictResult = expandConflictDefinitions( filterConflictMap );
+
+ // Set conflicts for groups
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( groupConflictResult, function ( group, conflicts ) {
+ model.groups[ group ].setConflicts( conflicts );
+ } );
+
+ // Set conflicts for items
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( filterConflictResult, function ( filterName, conflicts ) {
+ var filterItem = model.getItemByName( filterName );
+ // set conflicts for items in the group
+ filterItem.setConflicts( conflicts );
+ } );
+
+ // Create a map between known parameters and their models
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.groups, function ( group, groupModel ) {
+ if (
+ groupModel.getType() === 'send_unselected_if_any' ||
+ groupModel.getType() === 'boolean' ||
+ groupModel.getType() === 'any_value'
+ ) {
+ // Individual filters
+ groupModel.getItems().forEach( function ( filterItem ) {
+ model.parameterMap[ filterItem.getParamName() ] = filterItem;
+ } );
+ } else if (
+ groupModel.getType() === 'string_options' ||
+ groupModel.getType() === 'single_option'
+ ) {
+ // Group
+ model.parameterMap[ groupModel.getName() ] = groupModel;
+ }
+ } );
+
+ this.setSearch( '' );
+
+ this.updateHighlightedState();
+
+ // Finish initialization
+ this.emit( 'initialize' );
+};
+
+/**
+ * Update filter view model state based on a parameter object
+ *
+ * @param {Object} params Parameters object
+ */
+FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
+ var filtersValue;
+ // For arbitrary numeric single_option values make sure the values
+ // are normalized to fit within the limits
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+ params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
+ } );
+
+ // Update filter values
+ filtersValue = this.getFiltersFromParameters( params );
+ Object.keys( filtersValue ).forEach( function ( filterName ) {
+ this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
+ }.bind( this ) );
+
+ // Update highlight state
+ this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
+ var color = params[ filterItem.getName() + '_color' ];
+ if ( color ) {
+ filterItem.setHighlightColor( color );
+ } else {
+ filterItem.clearHighlightColor();
+ }
+ } );
+ this.updateHighlightedState();
+
+ // Check all filter interactions
+ this.reassessFilterInteractions();
+};
+
+/**
+ * Get a representation of an empty (falsey) parameter state
+ *
+ * @return {Object} Empty parameter state
+ */
+FiltersViewModel.prototype.getEmptyParameterState = function () {
+ if ( !this.emptyParameterState ) {
+ this.emptyParameterState = $.extend(
true,
{},
- this.getEmptyParameterState(),
- this.getCurrentParameterState()
+ this.getParametersFromFilters( {} ),
+ this.getEmptyHighlightParameters()
);
- };
-
- /**
- * Get a parameter representation of the current state of the model
- *
- * @param {boolean} [removeStickyParams] Remove sticky filters from final result
- * @return {Object} Parameter representation of the current state of the model
- */
- FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
- var state = this.getMinimizedParamRepresentation( $.extend(
- true,
- {},
- this.getParametersFromFilters( this.getSelectedState() ),
- this.getHighlightParameters()
- ) );
-
- if ( removeStickyParams ) {
- state = this.removeStickyParams( state );
+ }
+ return this.emptyParameterState;
+};
+
+/**
+ * Get a representation of only the non-falsey parameters
+ *
+ * @param {Object} [parameters] A given parameter state to minimize. If not given the current
+ * state of the system will be used.
+ * @return {Object} Empty parameter state
+ */
+FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
+ var result = {};
+
+ parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
+
+ // Params
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.getEmptyParameterState(), function ( param, value ) {
+ if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
+ result[ param ] = parameters[ param ];
}
-
- return state;
- };
-
- /**
- * Delete sticky parameters from given object.
- *
- * @param {Object} paramState Parameter state
- * @return {Object} Parameter state without sticky parameters
- */
- FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
- this.getStickyParams().forEach( function ( paramName ) {
- delete paramState[ paramName ];
- } );
-
- return paramState;
- };
-
- /**
- * Turn the highlight feature on or off
- */
- FiltersViewModel.prototype.updateHighlightedState = function () {
- this.toggleHighlight( this.getHighlightedItems().length > 0 );
- };
-
- /**
- * Get the object that defines groups by their name.
- *
- * @return {Object} Filter groups
- */
- FiltersViewModel.prototype.getFilterGroups = function () {
- return this.groups;
- };
-
- /**
- * Get the object that defines groups that match a certain view by their name.
- *
- * @param {string} [view] Requested view. If not given, uses current view
- * @return {Object} Filter groups matching a display group
- */
- FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
- var result = {};
-
- view = view || this.getCurrentView();
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.groups, function ( groupName, groupModel ) {
- if ( groupModel.getView() === view ) {
- result[ groupName ] = groupModel;
- }
- } );
-
- return result;
- };
-
- /**
- * Get an array of filters matching the given display group.
- *
- * @param {string} [view] Requested view. If not given, uses current view
- * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
- */
- FiltersViewModel.prototype.getFiltersByView = function ( view ) {
- var groups,
- result = [];
-
- view = view || this.getCurrentView();
-
- groups = this.getFilterGroupsByView( view );
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( groups, function ( groupName, groupModel ) {
- result = result.concat( groupModel.getItems() );
- } );
-
- return result;
- };
-
- /**
- * Get the trigger for the requested view.
- *
- * @param {string} view View name
- * @return {string} View trigger, if exists
- */
- FiltersViewModel.prototype.getViewTrigger = function ( view ) {
- return ( this.views[ view ] && this.views[ view ].trigger ) || '';
- };
-
- /**
- * Get the value of a specific parameter
- *
- * @param {string} name Parameter name
- * @return {number|string} Parameter value
- */
- FiltersViewModel.prototype.getParamValue = function ( name ) {
- return this.parameters[ name ];
- };
-
- /**
- * Get the current selected state of the filters
- *
- * @param {boolean} [onlySelected] return an object containing only the filters with a value
- * @return {Object} Filters selected state
- */
- FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
- var i,
- items = this.getItems(),
- result = {};
-
- for ( i = 0; i < items.length; i++ ) {
- if ( !onlySelected || items[ i ].getValue() ) {
- result[ items[ i ].getName() ] = items[ i ].getValue();
- }
+ } );
+
+ // Highlights
+ Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
+ if ( parameters[ param ] ) {
+ // If a highlight parameter is not undefined and not null
+ // add it to the result
+ result[ param ] = parameters[ param ];
}
-
- return result;
- };
-
- /**
- * Get the current full state of the filters
- *
- * @return {Object} Filters full state
- */
- FiltersViewModel.prototype.getFullState = function () {
- var i,
- items = this.getItems(),
- result = {};
-
- for ( i = 0; i < items.length; i++ ) {
- result[ items[ i ].getName() ] = {
- selected: items[ i ].isSelected(),
- conflicted: items[ i ].isConflicted(),
- included: items[ i ].isIncluded()
- };
+ } );
+
+ return result;
+};
+
+/**
+ * Get a representation of the full parameter list, including all base values
+ *
+ * @return {Object} Full parameter representation
+ */
+FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
+ return $.extend(
+ true,
+ {},
+ this.getEmptyParameterState(),
+ this.getCurrentParameterState()
+ );
+};
+
+/**
+ * Get a parameter representation of the current state of the model
+ *
+ * @param {boolean} [removeStickyParams] Remove sticky filters from final result
+ * @return {Object} Parameter representation of the current state of the model
+ */
+FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
+ var state = this.getMinimizedParamRepresentation( $.extend(
+ true,
+ {},
+ this.getParametersFromFilters( this.getSelectedState() ),
+ this.getHighlightParameters()
+ ) );
+
+ if ( removeStickyParams ) {
+ state = this.removeStickyParams( state );
+ }
+
+ return state;
+};
+
+/**
+ * Delete sticky parameters from given object.
+ *
+ * @param {Object} paramState Parameter state
+ * @return {Object} Parameter state without sticky parameters
+ */
+FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
+ this.getStickyParams().forEach( function ( paramName ) {
+ delete paramState[ paramName ];
+ } );
+
+ return paramState;
+};
+
+/**
+ * Turn the highlight feature on or off
+ */
+FiltersViewModel.prototype.updateHighlightedState = function () {
+ this.toggleHighlight( this.getHighlightedItems().length > 0 );
+};
+
+/**
+ * Get the object that defines groups by their name.
+ *
+ * @return {Object} Filter groups
+ */
+FiltersViewModel.prototype.getFilterGroups = function () {
+ return this.groups;
+};
+
+/**
+ * Get the object that defines groups that match a certain view by their name.
+ *
+ * @param {string} [view] Requested view. If not given, uses current view
+ * @return {Object} Filter groups matching a display group
+ */
+FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
+ var result = {};
+
+ view = view || this.getCurrentView();
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.groups, function ( groupName, groupModel ) {
+ if ( groupModel.getView() === view ) {
+ result[ groupName ] = groupModel;
}
-
- return result;
- };
-
- /**
- * Get an object representing default parameters state
- *
- * @return {Object} Default parameter values
- */
- FiltersViewModel.prototype.getDefaultParams = function () {
- var result = {};
-
- // Get default filter state
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.groups, function ( name, model ) {
- if ( !model.isSticky() ) {
- $.extend( true, result, model.getDefaultParams() );
- }
- } );
-
- return result;
- };
-
- /**
- * Get a parameter representation of all sticky parameters
- *
- * @return {Object} Sticky parameter values
- */
- FiltersViewModel.prototype.getStickyParams = function () {
- var result = [];
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.groups, function ( name, model ) {
- if ( model.isSticky() ) {
- if ( model.isPerGroupRequestParameter() ) {
- result.push( name );
- } else {
- // Each filter is its own param
- result = result.concat( model.getItems().map( function ( filterItem ) {
- return filterItem.getParamName();
- } ) );
- }
- }
- } );
-
- return result;
- };
-
- /**
- * Get a parameter representation of all sticky parameters
- *
- * @return {Object} Sticky parameter values
- */
- FiltersViewModel.prototype.getStickyParamsValues = function () {
- var result = {};
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.groups, function ( name, model ) {
- if ( model.isSticky() ) {
- $.extend( true, result, model.getParamRepresentation() );
- }
- } );
-
- return result;
- };
-
- /**
- * Analyze the groups and their filters and output an object representing
- * the state of the parameters they represent.
- *
- * @param {Object} [filterDefinition] An object defining the filter values,
- * keyed by filter names.
- * @return {Object} Parameter state object
- */
- FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
- var groupItemDefinition,
- result = {},
- groupItems = this.getFilterGroups();
-
- if ( filterDefinition ) {
- groupItemDefinition = {};
- // Filter definition is "flat", but in effect
- // each group needs to tell us its result based
- // on the values in it. We need to split this list
- // back into groupings so we can "feed" it to the
- // loop below, and we need to expand it so it includes
- // all filters (set to false)
- this.getItems().forEach( function ( filterItem ) {
- groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
- groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
- } );
+ } );
+
+ return result;
+};
+
+/**
+ * Get an array of filters matching the given display group.
+ *
+ * @param {string} [view] Requested view. If not given, uses current view
+ * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
+ */
+FiltersViewModel.prototype.getFiltersByView = function ( view ) {
+ var groups,
+ result = [];
+
+ view = view || this.getCurrentView();
+
+ groups = this.getFilterGroupsByView( view );
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( groups, function ( groupName, groupModel ) {
+ result = result.concat( groupModel.getItems() );
+ } );
+
+ return result;
+};
+
+/**
+ * Get the trigger for the requested view.
+ *
+ * @param {string} view View name
+ * @return {string} View trigger, if exists
+ */
+FiltersViewModel.prototype.getViewTrigger = function ( view ) {
+ return ( this.views[ view ] && this.views[ view ].trigger ) || '';
+};
+
+/**
+ * Get the value of a specific parameter
+ *
+ * @param {string} name Parameter name
+ * @return {number|string} Parameter value
+ */
+FiltersViewModel.prototype.getParamValue = function ( name ) {
+ return this.parameters[ name ];
+};
+
+/**
+ * Get the current selected state of the filters
+ *
+ * @param {boolean} [onlySelected] return an object containing only the filters with a value
+ * @return {Object} Filters selected state
+ */
+FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
+ var i,
+ items = this.getItems(),
+ result = {};
+
+ for ( i = 0; i < items.length; i++ ) {
+ if ( !onlySelected || items[ i ].getValue() ) {
+ result[ items[ i ].getName() ] = items[ i ].getValue();
}
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( groupItems, function ( group, model ) {
- $.extend(
- result,
- model.getParamRepresentation(
- groupItemDefinition ?
- groupItemDefinition[ group ] : null
- )
- );
- } );
-
- return result;
- };
-
- /**
- * This is the opposite of the #getParametersFromFilters method; this goes over
- * the given parameters and translates into a selected/unselected value in the filters.
- *
- * @param {Object} params Parameters query object
- * @return {Object} Filter state object
- */
- FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
- var groupMap = {},
- model = this,
- result = {};
-
- // Go over the given parameters, break apart to groupings
- // The resulting object represents the group with its parameter
- // values. For example:
- // {
- // group1: {
- // param1: "1",
- // param2: "0",
- // param3: "1"
- // },
- // group2: "param4|param5"
- // }
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( params, function ( paramName, paramValue ) {
- var groupName,
- itemOrGroup = model.parameterMap[ paramName ];
-
- if ( itemOrGroup ) {
- groupName = itemOrGroup instanceof FilterItem ?
- itemOrGroup.getGroupName() : itemOrGroup.getName();
-
- groupMap[ groupName ] = groupMap[ groupName ] || {};
- groupMap[ groupName ][ paramName ] = paramValue;
- }
- } );
-
- // Go over all groups, so we make sure we get the complete output
- // even if the parameters don't include a certain group
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.groups, function ( groupName, groupModel ) {
- result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
- } );
-
- return result;
- };
-
- /**
- * Get the highlight parameters based on current filter configuration
- *
- * @return {Object} Object where keys are `<filter name>_color` and values
- * are the selected highlight colors.
- */
- FiltersViewModel.prototype.getHighlightParameters = function () {
- var highlightEnabled = this.isHighlightEnabled(),
- result = {};
-
- this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isHighlightSupported() ) {
- result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
- filterItem.getHighlightColor() :
- null;
+ }
+
+ return result;
+};
+
+/**
+ * Get the current full state of the filters
+ *
+ * @return {Object} Filters full state
+ */
+FiltersViewModel.prototype.getFullState = function () {
+ var i,
+ items = this.getItems(),
+ result = {};
+
+ for ( i = 0; i < items.length; i++ ) {
+ result[ items[ i ].getName() ] = {
+ selected: items[ i ].isSelected(),
+ conflicted: items[ i ].isConflicted(),
+ included: items[ i ].isIncluded()
+ };
+ }
+
+ return result;
+};
+
+/**
+ * Get an object representing default parameters state
+ *
+ * @return {Object} Default parameter values
+ */
+FiltersViewModel.prototype.getDefaultParams = function () {
+ var result = {};
+
+ // Get default filter state
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.groups, function ( name, model ) {
+ if ( !model.isSticky() ) {
+ $.extend( true, result, model.getDefaultParams() );
+ }
+ } );
+
+ return result;
+};
+
+/**
+ * Get a parameter representation of all sticky parameters
+ *
+ * @return {Object} Sticky parameter values
+ */
+FiltersViewModel.prototype.getStickyParams = function () {
+ var result = [];
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.groups, function ( name, model ) {
+ if ( model.isSticky() ) {
+ if ( model.isPerGroupRequestParameter() ) {
+ result.push( name );
+ } else {
+ // Each filter is its own param
+ result = result.concat( model.getItems().map( function ( filterItem ) {
+ return filterItem.getParamName();
+ } ) );
}
- } );
-
- return result;
- };
-
- /**
- * Get an object representing the complete empty state of highlights
- *
- * @return {Object} Object containing all the highlight parameters set to their negative value
- */
- FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
- var result = {};
-
+ }
+ } );
+
+ return result;
+};
+
+/**
+ * Get a parameter representation of all sticky parameters
+ *
+ * @return {Object} Sticky parameter values
+ */
+FiltersViewModel.prototype.getStickyParamsValues = function () {
+ var result = {};
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.groups, function ( name, model ) {
+ if ( model.isSticky() ) {
+ $.extend( true, result, model.getParamRepresentation() );
+ }
+ } );
+
+ return result;
+};
+
+/**
+ * Analyze the groups and their filters and output an object representing
+ * the state of the parameters they represent.
+ *
+ * @param {Object} [filterDefinition] An object defining the filter values,
+ * keyed by filter names.
+ * @return {Object} Parameter state object
+ */
+FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
+ var groupItemDefinition,
+ result = {},
+ groupItems = this.getFilterGroups();
+
+ if ( filterDefinition ) {
+ groupItemDefinition = {};
+ // Filter definition is "flat", but in effect
+ // each group needs to tell us its result based
+ // on the values in it. We need to split this list
+ // back into groupings so we can "feed" it to the
+ // loop below, and we need to expand it so it includes
+ // all filters (set to false)
this.getItems().forEach( function ( filterItem ) {
- if ( filterItem.isHighlightSupported() ) {
- result[ filterItem.getName() + '_color' ] = null;
- }
+ groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
+ groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
} );
+ }
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( groupItems, function ( group, model ) {
+ $.extend(
+ result,
+ model.getParamRepresentation(
+ groupItemDefinition ?
+ groupItemDefinition[ group ] : null
+ )
+ );
+ } );
+
+ return result;
+};
+
+/**
+ * This is the opposite of the #getParametersFromFilters method; this goes over
+ * the given parameters and translates into a selected/unselected value in the filters.
+ *
+ * @param {Object} params Parameters query object
+ * @return {Object} Filter state object
+ */
+FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+ var groupMap = {},
+ model = this,
+ result = {};
+
+ // Go over the given parameters, break apart to groupings
+ // The resulting object represents the group with its parameter
+ // values. For example:
+ // {
+ // group1: {
+ // param1: "1",
+ // param2: "0",
+ // param3: "1"
+ // },
+ // group2: "param4|param5"
+ // }
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( params, function ( paramName, paramValue ) {
+ var groupName,
+ itemOrGroup = model.parameterMap[ paramName ];
+
+ if ( itemOrGroup ) {
+ groupName = itemOrGroup instanceof FilterItem ?
+ itemOrGroup.getGroupName() : itemOrGroup.getName();
+
+ groupMap[ groupName ] = groupMap[ groupName ] || {};
+ groupMap[ groupName ][ paramName ] = paramValue;
+ }
+ } );
+
+ // Go over all groups, so we make sure we get the complete output
+ // even if the parameters don't include a certain group
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.groups, function ( groupName, groupModel ) {
+ result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
+ } );
+
+ return result;
+};
+
+/**
+ * Get the highlight parameters based on current filter configuration
+ *
+ * @return {Object} Object where keys are `<filter name>_color` and values
+ * are the selected highlight colors.
+ */
+FiltersViewModel.prototype.getHighlightParameters = function () {
+ var highlightEnabled = this.isHighlightEnabled(),
+ result = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isHighlightSupported() ) {
+ result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
+ filterItem.getHighlightColor() :
+ null;
+ }
+ } );
+
+ return result;
+};
+
+/**
+ * Get an object representing the complete empty state of highlights
+ *
+ * @return {Object} Object containing all the highlight parameters set to their negative value
+ */
+FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
+ var result = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ if ( filterItem.isHighlightSupported() ) {
+ result[ filterItem.getName() + '_color' ] = null;
+ }
+ } );
- return result;
- };
-
- /**
- * Get an array of currently applied highlight colors
- *
- * @return {string[]} Currently applied highlight colors
- */
- FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
- var result = [];
+ return result;
+};
- if ( this.isHighlightEnabled() ) {
- this.getHighlightedItems().forEach( function ( filterItem ) {
- var color = filterItem.getHighlightColor();
+/**
+ * Get an array of currently applied highlight colors
+ *
+ * @return {string[]} Currently applied highlight colors
+ */
+FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
+ var result = [];
- if ( result.indexOf( color ) === -1 ) {
- result.push( color );
- }
- } );
- }
+ if ( this.isHighlightEnabled() ) {
+ this.getHighlightedItems().forEach( function ( filterItem ) {
+ var color = filterItem.getHighlightColor();
- return result;
- };
-
- /**
- * Sanitize value group of a string_option groups type
- * Remove duplicates and make sure to only use valid
- * values.
- *
- * @private
- * @param {string} groupName Group name
- * @param {string[]} valueArray Array of values
- * @return {string[]} Array of valid values
- */
- FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
- var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
- return filterItem.getParamName();
+ if ( result.indexOf( color ) === -1 ) {
+ result.push( color );
+ }
} );
-
- return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
- };
-
- /**
- * Check whether no visible filter is selected.
- *
- * Filter groups that are hidden or sticky are not shown in the
- * active filters area and therefore not included in this check.
- *
- * @return {boolean} No visible filter is selected
- */
- FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
- // Check if there are either any selected items or any items
- // that have highlight enabled
- return !this.getItems().some( function ( filterItem ) {
- var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
- active = ( filterItem.isSelected() || filterItem.isHighlighted() );
- return visible && active;
+ }
+
+ return result;
+};
+
+/**
+ * Sanitize value group of a string_option groups type
+ * Remove duplicates and make sure to only use valid
+ * values.
+ *
+ * @private
+ * @param {string} groupName Group name
+ * @param {string[]} valueArray Array of values
+ * @return {string[]} Array of valid values
+ */
+FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
+ var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
+ return filterItem.getParamName();
+ } );
+
+ return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
+};
+
+/**
+ * Check whether no visible filter is selected.
+ *
+ * Filter groups that are hidden or sticky are not shown in the
+ * active filters area and therefore not included in this check.
+ *
+ * @return {boolean} No visible filter is selected
+ */
+FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
+ // Check if there are either any selected items or any items
+ // that have highlight enabled
+ return !this.getItems().some( function ( filterItem ) {
+ var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
+ active = ( filterItem.isSelected() || filterItem.isHighlighted() );
+ return visible && active;
+ } );
+};
+
+/**
+ * Check whether the invert state is a valid one. A valid invert state is one where
+ * there are actual namespaces selected.
+ *
+ * This is done to compare states to previous ones that may have had the invert model
+ * selected but effectively had no namespaces, so are not effectively different than
+ * ones where invert is not selected.
+ *
+ * @return {boolean} Invert is effectively selected
+ */
+FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
+ return this.getInvertModel().isSelected() &&
+ this.findSelectedItems().some( function ( itemModel ) {
+ return itemModel.getGroupModel().getName() === 'namespace';
} );
- };
-
- /**
- * Check whether the invert state is a valid one. A valid invert state is one where
- * there are actual namespaces selected.
- *
- * This is done to compare states to previous ones that may have had the invert model
- * selected but effectively had no namespaces, so are not effectively different than
- * ones where invert is not selected.
- *
- * @return {boolean} Invert is effectively selected
- */
- FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
- return this.getInvertModel().isSelected() &&
- this.findSelectedItems().some( function ( itemModel ) {
- return itemModel.getGroupModel().getName() === 'namespace';
- } );
- };
-
- /**
- * Get the item that matches the given name
- *
- * @param {string} name Filter name
- * @return {mw.rcfilters.dm.FilterItem} Filter item
- */
- FiltersViewModel.prototype.getItemByName = function ( name ) {
- return this.getItems().filter( function ( item ) {
- return name === item.getName();
- } )[ 0 ];
- };
-
- /**
- * Set all filters to false or empty/all
- * This is equivalent to display all.
- */
- FiltersViewModel.prototype.emptyAllFilters = function () {
- this.getItems().forEach( function ( filterItem ) {
- if ( !filterItem.getGroupModel().isSticky() ) {
- this.toggleFilterSelected( filterItem.getName(), false );
- }
- }.bind( this ) );
- };
-
- /**
- * Toggle selected state of one item
- *
- * @param {string} name Name of the filter item
- * @param {boolean} [isSelected] Filter selected state
- */
- FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
- var item = this.getItemByName( name );
-
- if ( item ) {
- item.toggleSelected( isSelected );
+};
+
+/**
+ * Get the item that matches the given name
+ *
+ * @param {string} name Filter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+FiltersViewModel.prototype.getItemByName = function ( name ) {
+ return this.getItems().filter( function ( item ) {
+ return name === item.getName();
+ } )[ 0 ];
+};
+
+/**
+ * Set all filters to false or empty/all
+ * This is equivalent to display all.
+ */
+FiltersViewModel.prototype.emptyAllFilters = function () {
+ this.getItems().forEach( function ( filterItem ) {
+ if ( !filterItem.getGroupModel().isSticky() ) {
+ this.toggleFilterSelected( filterItem.getName(), false );
}
- };
-
- /**
- * Toggle selected state of items by their names
- *
- * @param {Object} filterDef Filter definitions
- */
- FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
- Object.keys( filterDef ).forEach( function ( name ) {
- this.toggleFilterSelected( name, filterDef[ name ] );
- }.bind( this ) );
- };
-
- /**
- * Get a group model from its name
- *
- * @param {string} groupName Group name
- * @return {mw.rcfilters.dm.FilterGroup} Group model
- */
- FiltersViewModel.prototype.getGroup = function ( groupName ) {
- return this.groups[ groupName ];
- };
-
- /**
- * Get all filters within a specified group by its name
- *
- * @param {string} groupName Group name
- * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
- */
- FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
- return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
- };
-
- /**
- * Find items whose labels match the given string
- *
- * @param {string} query Search string
- * @param {boolean} [returnFlat] Return a flat array. If false, the result
- * is an object whose keys are the group names and values are an array of
- * filters per group. If set to true, returns an array of filters regardless
- * of their groups.
- * @return {Object} An object of items to show
- * arranged by their group names
- */
- FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
- var i, searchIsEmpty,
- groupTitle,
- result = {},
- flatResult = [],
- view = this.getViewByTrigger( query.substr( 0, 1 ) ),
- items = this.getFiltersByView( view );
-
- // Normalize so we can search strings regardless of case and view
- query = query.trim().toLowerCase();
- if ( view !== 'default' ) {
- query = query.substr( 1 );
+ }.bind( this ) );
+};
+
+/**
+ * Toggle selected state of one item
+ *
+ * @param {string} name Name of the filter item
+ * @param {boolean} [isSelected] Filter selected state
+ */
+FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
+ var item = this.getItemByName( name );
+
+ if ( item ) {
+ item.toggleSelected( isSelected );
+ }
+};
+
+/**
+ * Toggle selected state of items by their names
+ *
+ * @param {Object} filterDef Filter definitions
+ */
+FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
+ Object.keys( filterDef ).forEach( function ( name ) {
+ this.toggleFilterSelected( name, filterDef[ name ] );
+ }.bind( this ) );
+};
+
+/**
+ * Get a group model from its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterGroup} Group model
+ */
+FiltersViewModel.prototype.getGroup = function ( groupName ) {
+ return this.groups[ groupName ];
+};
+
+/**
+ * Get all filters within a specified group by its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
+ */
+FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
+ return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
+};
+
+/**
+ * Find items whose labels match the given string
+ *
+ * @param {string} query Search string
+ * @param {boolean} [returnFlat] Return a flat array. If false, the result
+ * is an object whose keys are the group names and values are an array of
+ * filters per group. If set to true, returns an array of filters regardless
+ * of their groups.
+ * @return {Object} An object of items to show
+ * arranged by their group names
+ */
+FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
+ var i, searchIsEmpty,
+ groupTitle,
+ result = {},
+ flatResult = [],
+ view = this.getViewByTrigger( query.substr( 0, 1 ) ),
+ items = this.getFiltersByView( view );
+
+ // Normalize so we can search strings regardless of case and view
+ query = query.trim().toLowerCase();
+ if ( view !== 'default' ) {
+ query = query.substr( 1 );
+ }
+ // Trim again to also intercept cases where the spaces were after the trigger
+ // eg: '# str'
+ query = query.trim();
+
+ // Check if the search if actually empty; this can be a problem when
+ // we use prefixes to denote different views
+ searchIsEmpty = query.length === 0;
+
+ // item label starting with the query string
+ for ( i = 0; i < items.length; i++ ) {
+ if (
+ searchIsEmpty ||
+ items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+ (
+ // For tags, we want the parameter name to be included in the search
+ view === 'tags' &&
+ items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+ )
+ ) {
+ result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+ result[ items[ i ].getGroupName() ].push( items[ i ] );
+ flatResult.push( items[ i ] );
}
- // Trim again to also intercept cases where the spaces were after the trigger
- // eg: '# str'
- query = query.trim();
+ }
- // Check if the search if actually empty; this can be a problem when
- // we use prefixes to denote different views
- searchIsEmpty = query.length === 0;
-
- // item label starting with the query string
+ if ( $.isEmptyObject( result ) ) {
+ // item containing the query string in their label, description, or group title
for ( i = 0; i < items.length; i++ ) {
+ groupTitle = items[ i ].getGroupModel().getTitle();
if (
searchIsEmpty ||
- items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+ items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
+ items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
+ groupTitle.toLowerCase().indexOf( query ) > -1 ||
(
// For tags, we want the parameter name to be included in the search
view === 'tags' &&
flatResult.push( items[ i ] );
}
}
-
- if ( $.isEmptyObject( result ) ) {
- // item containing the query string in their label, description, or group title
- for ( i = 0; i < items.length; i++ ) {
- groupTitle = items[ i ].getGroupModel().getTitle();
- if (
- searchIsEmpty ||
- items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
- items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
- groupTitle.toLowerCase().indexOf( query ) > -1 ||
- (
- // For tags, we want the parameter name to be included in the search
- view === 'tags' &&
- items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
- )
- ) {
- result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
- result[ items[ i ].getGroupName() ].push( items[ i ] );
- flatResult.push( items[ i ] );
- }
- }
+ }
+
+ return returnFlat ? flatResult : result;
+};
+
+/**
+ * Get items that are highlighted
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
+ */
+FiltersViewModel.prototype.getHighlightedItems = function () {
+ return this.getItems().filter( function ( filterItem ) {
+ return filterItem.isHighlightSupported() &&
+ filterItem.getHighlightColor();
+ } );
+};
+
+/**
+ * Get items that allow highlights even if they're not currently highlighted
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
+ */
+FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
+ return this.getItems().filter( function ( filterItem ) {
+ return filterItem.isHighlightSupported();
+ } );
+};
+
+/**
+ * Get all selected items
+ *
+ * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+ */
+FiltersViewModel.prototype.findSelectedItems = function () {
+ var allSelected = [];
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+ allSelected = allSelected.concat( groupModel.findSelectedItems() );
+ } );
+
+ return allSelected;
+};
+
+/**
+ * Get the current view
+ *
+ * @return {string} Current view
+ */
+FiltersViewModel.prototype.getCurrentView = function () {
+ return this.currentView;
+};
+
+/**
+ * Get the label for the current view
+ *
+ * @param {string} viewName View name
+ * @return {string} Label for the current view
+ */
+FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
+ viewName = viewName || this.getCurrentView();
+
+ return this.views[ viewName ] && this.views[ viewName ].title;
+};
+
+/**
+ * Get the view that fits the given trigger
+ *
+ * @param {string} trigger Trigger
+ * @return {string} Name of view
+ */
+FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+ var result = 'default';
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( this.views, function ( name, data ) {
+ if ( data.trigger === trigger ) {
+ result = name;
}
-
- return returnFlat ? flatResult : result;
- };
-
- /**
- * Get items that are highlighted
- *
- * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
- */
- FiltersViewModel.prototype.getHighlightedItems = function () {
- return this.getItems().filter( function ( filterItem ) {
- return filterItem.isHighlightSupported() &&
- filterItem.getHighlightColor();
- } );
- };
-
- /**
- * Get items that allow highlights even if they're not currently highlighted
- *
- * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
- */
- FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
- return this.getItems().filter( function ( filterItem ) {
- return filterItem.isHighlightSupported();
- } );
- };
-
- /**
- * Get all selected items
- *
- * @return {mw.rcfilters.dm.FilterItem[]} Selected items
- */
- FiltersViewModel.prototype.findSelectedItems = function () {
- var allSelected = [];
-
+ } );
+
+ return result;
+};
+
+/**
+ * Return a version of the given string that is without any
+ * view triggers.
+ *
+ * @param {string} str Given string
+ * @return {string} Result
+ */
+FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
+ if ( this.getViewFromString( str ) !== 'default' ) {
+ str = str.substr( 1 );
+ }
+
+ return str;
+};
+
+/**
+ * Get the view from the given string by a trigger, if it exists
+ *
+ * @param {string} str Given string
+ * @return {string} View name
+ */
+FiltersViewModel.prototype.getViewFromString = function ( str ) {
+ return this.getViewByTrigger( str.substr( 0, 1 ) );
+};
+
+/**
+ * Set the current search for the system.
+ * This also dictates what items and groups are visible according
+ * to the search in #findMatches
+ *
+ * @param {string} searchQuery Search query, including triggers
+ * @fires searchChange
+ */
+FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
+ var visibleGroups, visibleGroupNames;
+
+ if ( this.searchQuery !== searchQuery ) {
+ // Check if the view changed
+ this.switchView( this.getViewFromString( searchQuery ) );
+
+ visibleGroups = this.findMatches( searchQuery );
+ visibleGroupNames = Object.keys( visibleGroups );
+
+ // Update visibility of items and groups
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.getFilterGroups(), function ( groupName, groupModel ) {
- allSelected = allSelected.concat( groupModel.findSelectedItems() );
- } );
-
- return allSelected;
- };
-
- /**
- * Get the current view
- *
- * @return {string} Current view
- */
- FiltersViewModel.prototype.getCurrentView = function () {
- return this.currentView;
- };
-
- /**
- * Get the label for the current view
- *
- * @param {string} viewName View name
- * @return {string} Label for the current view
- */
- FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
- viewName = viewName || this.getCurrentView();
-
- return this.views[ viewName ] && this.views[ viewName ].title;
- };
-
- /**
- * Get the view that fits the given trigger
- *
- * @param {string} trigger Trigger
- * @return {string} Name of view
- */
- FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
- var result = 'default';
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.views, function ( name, data ) {
- if ( data.trigger === trigger ) {
- result = name;
- }
+ // Check if the group is visible at all
+ groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
+ groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
} );
- return result;
- };
-
- /**
- * Return a version of the given string that is without any
- * view triggers.
- *
- * @param {string} str Given string
- * @return {string} Result
- */
- FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
- if ( this.getViewFromString( str ) !== 'default' ) {
- str = str.substr( 1 );
- }
-
- return str;
- };
-
- /**
- * Get the view from the given string by a trigger, if it exists
- *
- * @param {string} str Given string
- * @return {string} View name
- */
- FiltersViewModel.prototype.getViewFromString = function ( str ) {
- return this.getViewByTrigger( str.substr( 0, 1 ) );
- };
-
- /**
- * Set the current search for the system.
- * This also dictates what items and groups are visible according
- * to the search in #findMatches
- *
- * @param {string} searchQuery Search query, including triggers
- * @fires searchChange
- */
- FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
- var visibleGroups, visibleGroupNames;
-
- if ( this.searchQuery !== searchQuery ) {
- // Check if the view changed
- this.switchView( this.getViewFromString( searchQuery ) );
-
- visibleGroups = this.findMatches( searchQuery );
- visibleGroupNames = Object.keys( visibleGroups );
-
- // Update visibility of items and groups
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
- // Check if the group is visible at all
- groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
- groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
- } );
-
- this.searchQuery = searchQuery;
- this.emit( 'searchChange', this.searchQuery );
- }
- };
-
- /**
- * Get the current search
- *
- * @return {string} Current search query
- */
- FiltersViewModel.prototype.getSearch = function () {
- return this.searchQuery;
- };
-
- /**
- * Switch the current view
- *
- * @private
- * @param {string} view View name
- */
- FiltersViewModel.prototype.switchView = function ( view ) {
- if ( this.views[ view ] && this.currentView !== view ) {
- this.currentView = view;
- }
- };
-
- /**
- * Toggle the highlight feature on and off.
- * Propagate the change to filter items.
- *
- * @param {boolean} enable Highlight should be enabled
- * @fires highlightChange
- */
- FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
- enable = enable === undefined ? !this.highlightEnabled : enable;
-
- if ( this.highlightEnabled !== enable ) {
- this.highlightEnabled = enable;
- this.emit( 'highlightChange', this.highlightEnabled );
- }
- };
-
- /**
- * Check if the highlight feature is enabled
- * @return {boolean}
- */
- FiltersViewModel.prototype.isHighlightEnabled = function () {
- return !!this.highlightEnabled;
- };
-
- /**
- * Toggle the inverted namespaces property on and off.
- * Propagate the change to namespace filter items.
- *
- * @param {boolean} enable Inverted property is enabled
- */
- FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
- this.toggleFilterSelected( this.getInvertModel().getName(), enable );
- };
-
- /**
- * Get the model object that represents the 'invert' filter
- *
- * @return {mw.rcfilters.dm.FilterItem}
- */
- FiltersViewModel.prototype.getInvertModel = function () {
- return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
- };
-
- /**
- * Set highlight color for a specific filter item
- *
- * @param {string} filterName Name of the filter item
- * @param {string} color Selected color
- */
- FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
- this.getItemByName( filterName ).setHighlightColor( color );
- };
-
- /**
- * Clear highlight for a specific filter item
- *
- * @param {string} filterName Name of the filter item
- */
- FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
- this.getItemByName( filterName ).clearHighlightColor();
- };
-
- module.exports = FiltersViewModel;
-
-}() );
+ this.searchQuery = searchQuery;
+ this.emit( 'searchChange', this.searchQuery );
+ }
+};
+
+/**
+ * Get the current search
+ *
+ * @return {string} Current search query
+ */
+FiltersViewModel.prototype.getSearch = function () {
+ return this.searchQuery;
+};
+
+/**
+ * Switch the current view
+ *
+ * @private
+ * @param {string} view View name
+ */
+FiltersViewModel.prototype.switchView = function ( view ) {
+ if ( this.views[ view ] && this.currentView !== view ) {
+ this.currentView = view;
+ }
+};
+
+/**
+ * Toggle the highlight feature on and off.
+ * Propagate the change to filter items.
+ *
+ * @param {boolean} enable Highlight should be enabled
+ * @fires highlightChange
+ */
+FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
+ enable = enable === undefined ? !this.highlightEnabled : enable;
+
+ if ( this.highlightEnabled !== enable ) {
+ this.highlightEnabled = enable;
+ this.emit( 'highlightChange', this.highlightEnabled );
+ }
+};
+
+/**
+ * Check if the highlight feature is enabled
+ * @return {boolean}
+ */
+FiltersViewModel.prototype.isHighlightEnabled = function () {
+ return !!this.highlightEnabled;
+};
+
+/**
+ * Toggle the inverted namespaces property on and off.
+ * Propagate the change to namespace filter items.
+ *
+ * @param {boolean} enable Inverted property is enabled
+ */
+FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
+ this.toggleFilterSelected( this.getInvertModel().getName(), enable );
+};
+
+/**
+ * Get the model object that represents the 'invert' filter
+ *
+ * @return {mw.rcfilters.dm.FilterItem}
+ */
+FiltersViewModel.prototype.getInvertModel = function () {
+ return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
+};
+
+/**
+ * Set highlight color for a specific filter item
+ *
+ * @param {string} filterName Name of the filter item
+ * @param {string} color Selected color
+ */
+FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
+ this.getItemByName( filterName ).setHighlightColor( color );
+};
+
+/**
+ * Clear highlight for a specific filter item
+ *
+ * @param {string} filterName Name of the filter item
+ */
+FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
+ this.getItemByName( filterName ).clearHighlightColor();
+};
+
+module.exports = FiltersViewModel;
-( function () {
- /**
- * RCFilter base item model
- *
- * @class mw.rcfilters.dm.ItemModel
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {string} param Filter param name
- * @param {Object} config Configuration object
- * @cfg {string} [label] The label for the filter
- * @cfg {string} [description] The description of the filter
- * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
- * group. If the prefix has 'invert' state, the parameter is expected to be an object
- * with 'default' and 'inverted' as keys.
- * @cfg {boolean} [active=true] The filter is active and affecting the result
- * @cfg {boolean} [selected] The item is selected
- * @cfg {*} [value] The value of this item
- * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
- * identifier
- * @cfg {string} [cssClass] The class identifying the results that match this filter
- * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
- * added and considered in the view.
- * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
- */
- var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- this.param = param;
- this.namePrefix = config.namePrefix || 'item_';
- this.name = this.namePrefix + param;
-
- this.label = config.label || this.name;
- this.labelPrefixKey = config.labelPrefixKey;
- this.description = config.description || '';
- this.setValue( config.value || config.selected );
-
- this.identifiers = config.identifiers || [];
-
- // Highlight
- this.cssClass = config.cssClass;
- this.highlightColor = config.defaultHighlightColor || null;
+/**
+ * RCFilter base item model
+ *
+ * @class mw.rcfilters.dm.ItemModel
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {string} param Filter param name
+ * @param {Object} config Configuration object
+ * @cfg {string} [label] The label for the filter
+ * @cfg {string} [description] The description of the filter
+ * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+ * group. If the prefix has 'invert' state, the parameter is expected to be an object
+ * with 'default' and 'inverted' as keys.
+ * @cfg {boolean} [active=true] The filter is active and affecting the result
+ * @cfg {boolean} [selected] The item is selected
+ * @cfg {*} [value] The value of this item
+ * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
+ * identifier
+ * @cfg {string} [cssClass] The class identifying the results that match this filter
+ * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
+ * added and considered in the view.
+ * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
+ */
+var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ this.param = param;
+ this.namePrefix = config.namePrefix || 'item_';
+ this.name = this.namePrefix + param;
+
+ this.label = config.label || this.name;
+ this.labelPrefixKey = config.labelPrefixKey;
+ this.description = config.description || '';
+ this.setValue( config.value || config.selected );
+
+ this.identifiers = config.identifiers || [];
+
+ // Highlight
+ this.cssClass = config.cssClass;
+ this.highlightColor = config.defaultHighlightColor || null;
+};
+
+/* Initialization */
+
+OO.initClass( ItemModel );
+OO.mixinClass( ItemModel, OO.EventEmitter );
+
+/* Events */
+
+/**
+ * @event update
+ *
+ * The state of this filter has changed
+ */
+
+/* Methods */
+
+/**
+ * Return the representation of the state of this item.
+ *
+ * @return {Object} State of the object
+ */
+ItemModel.prototype.getState = function () {
+ return {
+ selected: this.isSelected()
};
-
- /* Initialization */
-
- OO.initClass( ItemModel );
- OO.mixinClass( ItemModel, OO.EventEmitter );
-
- /* Events */
-
- /**
- * @event update
- *
- * The state of this filter has changed
- */
-
- /* Methods */
-
- /**
- * Return the representation of the state of this item.
- *
- * @return {Object} State of the object
- */
- ItemModel.prototype.getState = function () {
- return {
- selected: this.isSelected()
- };
- };
-
- /**
- * Get the name of this filter
- *
- * @return {string} Filter name
- */
- ItemModel.prototype.getName = function () {
- return this.name;
- };
-
- /**
- * Get the message key to use to wrap the label. This message takes the label as a parameter.
- *
- * @param {boolean} inverted Whether this item should be considered inverted
- * @return {string|null} Message key, or null if no message
- */
- ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
- if ( this.labelPrefixKey ) {
- if ( typeof this.labelPrefixKey === 'string' ) {
- return this.labelPrefixKey;
- }
- return this.labelPrefixKey[
- // Only use inverted-prefix if the item is selected
- // Highlight-only an inverted item makes no sense
- inverted && this.isSelected() ?
- 'inverted' : 'default'
- ];
+};
+
+/**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ItemModel.prototype.getName = function () {
+ return this.name;
+};
+
+/**
+ * Get the message key to use to wrap the label. This message takes the label as a parameter.
+ *
+ * @param {boolean} inverted Whether this item should be considered inverted
+ * @return {string|null} Message key, or null if no message
+ */
+ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
+ if ( this.labelPrefixKey ) {
+ if ( typeof this.labelPrefixKey === 'string' ) {
+ return this.labelPrefixKey;
}
- return null;
- };
-
- /**
- * Get the param name or value of this filter
- *
- * @return {string} Filter param name
- */
- ItemModel.prototype.getParamName = function () {
- return this.param;
- };
-
- /**
- * Get the message representing the state of this model.
- *
- * @return {string} State message
- */
- ItemModel.prototype.getStateMessage = function () {
- // Display description
- return this.getDescription();
- };
-
- /**
- * Get the label of this filter
- *
- * @return {string} Filter label
- */
- ItemModel.prototype.getLabel = function () {
- return this.label;
- };
-
- /**
- * Get the description of this filter
- *
- * @return {string} Filter description
- */
- ItemModel.prototype.getDescription = function () {
- return this.description;
- };
-
- /**
- * Get the default value of this filter
- *
- * @return {boolean} Filter default
- */
- ItemModel.prototype.getDefault = function () {
- return this.default;
- };
-
- /**
- * Get the selected state of this filter
- *
- * @return {boolean} Filter is selected
- */
- ItemModel.prototype.isSelected = function () {
- return !!this.value;
- };
-
- /**
- * Toggle the selected state of the item
- *
- * @param {boolean} [isSelected] Filter is selected
- * @fires update
- */
- ItemModel.prototype.toggleSelected = function ( isSelected ) {
- isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
- this.setValue( isSelected );
- };
-
- /**
- * Get the value
- *
- * @return {*}
- */
- ItemModel.prototype.getValue = function () {
- return this.value;
- };
-
- /**
- * Convert a given value to the appropriate representation based on group type
- *
- * @param {*} value
- * @return {*}
- */
- ItemModel.prototype.coerceValue = function ( value ) {
- return this.getGroupModel().getType() === 'any_value' ? value : !!value;
- };
-
- /**
- * Set the value
- *
- * @param {*} newValue
- */
- ItemModel.prototype.setValue = function ( newValue ) {
- newValue = this.coerceValue( newValue );
- if ( this.value !== newValue ) {
- this.value = newValue;
- this.emit( 'update' );
- }
- };
-
- /**
- * Set the highlight color
- *
- * @param {string|null} highlightColor
- */
- ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
- if ( !this.isHighlightSupported() ) {
- return;
- }
- // If the highlight color on the item and in the parameter is null/undefined, return early.
- if ( !this.highlightColor && !highlightColor ) {
- return;
- }
-
- if ( this.highlightColor !== highlightColor ) {
- this.highlightColor = highlightColor;
- this.emit( 'update' );
- }
- };
-
- /**
- * Clear the highlight color
- */
- ItemModel.prototype.clearHighlightColor = function () {
- this.setHighlightColor( null );
- };
-
- /**
- * Get the highlight color, or null if none is configured
- *
- * @return {string|null}
- */
- ItemModel.prototype.getHighlightColor = function () {
- return this.highlightColor;
- };
-
- /**
- * Get the CSS class that matches changes that fit this filter
- * or null if none is configured
- *
- * @return {string|null}
- */
- ItemModel.prototype.getCssClass = function () {
- return this.cssClass;
- };
-
- /**
- * Get the item's identifiers
- *
- * @return {string[]}
- */
- ItemModel.prototype.getIdentifiers = function () {
- return this.identifiers;
- };
-
- /**
- * Check if the highlight feature is supported for this filter
- *
- * @return {boolean}
- */
- ItemModel.prototype.isHighlightSupported = function () {
- return !!this.getCssClass();
- };
-
- /**
- * Check if the filter is currently highlighted
- *
- * @return {boolean}
- */
- ItemModel.prototype.isHighlighted = function () {
- return !!this.getHighlightColor();
- };
-
- module.exports = ItemModel;
-}() );
+ return this.labelPrefixKey[
+ // Only use inverted-prefix if the item is selected
+ // Highlight-only an inverted item makes no sense
+ inverted && this.isSelected() ?
+ 'inverted' : 'default'
+ ];
+ }
+ return null;
+};
+
+/**
+ * Get the param name or value of this filter
+ *
+ * @return {string} Filter param name
+ */
+ItemModel.prototype.getParamName = function () {
+ return this.param;
+};
+
+/**
+ * Get the message representing the state of this model.
+ *
+ * @return {string} State message
+ */
+ItemModel.prototype.getStateMessage = function () {
+ // Display description
+ return this.getDescription();
+};
+
+/**
+ * Get the label of this filter
+ *
+ * @return {string} Filter label
+ */
+ItemModel.prototype.getLabel = function () {
+ return this.label;
+};
+
+/**
+ * Get the description of this filter
+ *
+ * @return {string} Filter description
+ */
+ItemModel.prototype.getDescription = function () {
+ return this.description;
+};
+
+/**
+ * Get the default value of this filter
+ *
+ * @return {boolean} Filter default
+ */
+ItemModel.prototype.getDefault = function () {
+ return this.default;
+};
+
+/**
+ * Get the selected state of this filter
+ *
+ * @return {boolean} Filter is selected
+ */
+ItemModel.prototype.isSelected = function () {
+ return !!this.value;
+};
+
+/**
+ * Toggle the selected state of the item
+ *
+ * @param {boolean} [isSelected] Filter is selected
+ * @fires update
+ */
+ItemModel.prototype.toggleSelected = function ( isSelected ) {
+ isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
+ this.setValue( isSelected );
+};
+
+/**
+ * Get the value
+ *
+ * @return {*}
+ */
+ItemModel.prototype.getValue = function () {
+ return this.value;
+};
+
+/**
+ * Convert a given value to the appropriate representation based on group type
+ *
+ * @param {*} value
+ * @return {*}
+ */
+ItemModel.prototype.coerceValue = function ( value ) {
+ return this.getGroupModel().getType() === 'any_value' ? value : !!value;
+};
+
+/**
+ * Set the value
+ *
+ * @param {*} newValue
+ */
+ItemModel.prototype.setValue = function ( newValue ) {
+ newValue = this.coerceValue( newValue );
+ if ( this.value !== newValue ) {
+ this.value = newValue;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Set the highlight color
+ *
+ * @param {string|null} highlightColor
+ */
+ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
+ if ( !this.isHighlightSupported() ) {
+ return;
+ }
+ // If the highlight color on the item and in the parameter is null/undefined, return early.
+ if ( !this.highlightColor && !highlightColor ) {
+ return;
+ }
+
+ if ( this.highlightColor !== highlightColor ) {
+ this.highlightColor = highlightColor;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Clear the highlight color
+ */
+ItemModel.prototype.clearHighlightColor = function () {
+ this.setHighlightColor( null );
+};
+
+/**
+ * Get the highlight color, or null if none is configured
+ *
+ * @return {string|null}
+ */
+ItemModel.prototype.getHighlightColor = function () {
+ return this.highlightColor;
+};
+
+/**
+ * Get the CSS class that matches changes that fit this filter
+ * or null if none is configured
+ *
+ * @return {string|null}
+ */
+ItemModel.prototype.getCssClass = function () {
+ return this.cssClass;
+};
+
+/**
+ * Get the item's identifiers
+ *
+ * @return {string[]}
+ */
+ItemModel.prototype.getIdentifiers = function () {
+ return this.identifiers;
+};
+
+/**
+ * Check if the highlight feature is supported for this filter
+ *
+ * @return {boolean}
+ */
+ItemModel.prototype.isHighlightSupported = function () {
+ return !!this.getCssClass();
+};
+
+/**
+ * Check if the filter is currently highlighted
+ *
+ * @return {boolean}
+ */
+ItemModel.prototype.isHighlighted = function () {
+ return !!this.getHighlightColor();
+};
+
+module.exports = ItemModel;
-( function () {
- var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
- SavedQueriesModel;
-
- /**
- * View model for saved queries
- *
- * @class mw.rcfilters.dm.SavedQueriesModel
- * @mixins OO.EventEmitter
- * @mixins OO.EmitterList
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
- * @param {Object} [config] Configuration options
- * @cfg {string} [default] Default query ID
- */
- SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
- OO.EmitterList.call( this );
-
- this.default = config.default;
- this.filtersModel = filtersModel;
- this.converted = false;
-
- // Events
- this.aggregate( { update: 'itemUpdate' } );
- };
-
- /* Initialization */
-
- OO.initClass( SavedQueriesModel );
- OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
- OO.mixinClass( SavedQueriesModel, OO.EmitterList );
-
- /* Events */
-
- /**
- * @event initialize
- *
- * Model is initialized
- */
-
- /**
- * @event itemUpdate
- * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
- *
- * An item has changed
- */
-
- /**
- * @event default
- * @param {string} New default ID
- *
- * The default has changed
- */
-
- /* Methods */
-
- /**
- * Initialize the saved queries model by reading it from the user's settings.
- * The structure of the saved queries is:
- * {
- * version: (string) Version number; if version 2, the query represents
- * parameters. Otherwise, the older version represented filters
- * and needs to be readjusted,
- * default: (string) Query ID
- * queries:{
- * query_id_1: {
- * data:{
- * filters: (Object) Minimal definition of the filters
- * highlights: (Object) Definition of the highlights
- * },
- * label: (optional) Name of this query
- * }
- * }
- * }
- *
- * @param {Object} [savedQueries] An object with the saved queries with
- * the above structure.
- * @fires initialize
- */
- SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
- var model = this;
-
- savedQueries = savedQueries || {};
-
- this.clearItems();
- this.default = null;
- this.converted = false;
-
- if ( savedQueries.version !== '2' ) {
- // Old version dealt with filter names. We need to migrate to the new structure
- // The new structure:
- // {
- // version: (string) '2',
- // default: (string) Query ID,
- // queries: {
- // query_id: {
- // label: (string) Name of the query
- // data: {
- // params: (object) Representing all the parameter states
- // highlights: (object) Representing all the filter highlight states
- // }
- // }
- // }
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( savedQueries.queries || {}, function ( id, obj ) {
- if ( obj.data && obj.data.filters ) {
- obj.data = model.convertToParameters( obj.data );
- }
- } );
-
- this.converted = true;
- savedQueries.version = '2';
- }
-
- // Initialize the query items
+var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
+ SavedQueriesModel;
+
+/**
+ * View model for saved queries
+ *
+ * @class mw.rcfilters.dm.SavedQueriesModel
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [default] Default query ID
+ */
+SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+ OO.EmitterList.call( this );
+
+ this.default = config.default;
+ this.filtersModel = filtersModel;
+ this.converted = false;
+
+ // Events
+ this.aggregate( { update: 'itemUpdate' } );
+};
+
+/* Initialization */
+
+OO.initClass( SavedQueriesModel );
+OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
+OO.mixinClass( SavedQueriesModel, OO.EmitterList );
+
+/* Events */
+
+/**
+ * @event initialize
+ *
+ * Model is initialized
+ */
+
+/**
+ * @event itemUpdate
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
+ *
+ * An item has changed
+ */
+
+/**
+ * @event default
+ * @param {string} New default ID
+ *
+ * The default has changed
+ */
+
+/* Methods */
+
+/**
+ * Initialize the saved queries model by reading it from the user's settings.
+ * The structure of the saved queries is:
+ * {
+ * version: (string) Version number; if version 2, the query represents
+ * parameters. Otherwise, the older version represented filters
+ * and needs to be readjusted,
+ * default: (string) Query ID
+ * queries:{
+ * query_id_1: {
+ * data:{
+ * filters: (Object) Minimal definition of the filters
+ * highlights: (Object) Definition of the highlights
+ * },
+ * label: (optional) Name of this query
+ * }
+ * }
+ * }
+ *
+ * @param {Object} [savedQueries] An object with the saved queries with
+ * the above structure.
+ * @fires initialize
+ */
+SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
+ var model = this;
+
+ savedQueries = savedQueries || {};
+
+ this.clearItems();
+ this.default = null;
+ this.converted = false;
+
+ if ( savedQueries.version !== '2' ) {
+ // Old version dealt with filter names. We need to migrate to the new structure
+ // The new structure:
+ // {
+ // version: (string) '2',
+ // default: (string) Query ID,
+ // queries: {
+ // query_id: {
+ // label: (string) Name of the query
+ // data: {
+ // params: (object) Representing all the parameter states
+ // highlights: (object) Representing all the filter highlight states
+ // }
+ // }
+ // }
// eslint-disable-next-line no-jquery/no-each-util
$.each( savedQueries.queries || {}, function ( id, obj ) {
- var normalizedData = obj.data,
- isDefault = String( savedQueries.default ) === String( id );
-
- if ( normalizedData && normalizedData.params ) {
- // Backwards-compat fix: Remove sticky parameters from
- // the given data, if they exist
- normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
-
- // Correct the invert state for effective selection
- if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
- delete normalizedData.params.invert;
- }
-
- model.cleanupHighlights( normalizedData );
-
- id = String( id );
-
- // Skip the addNewQuery method because we don't want to unnecessarily manipulate
- // the given saved queries unless we literally intend to (like in backwards compat fixes)
- // And the addNewQuery method also uses a minimization routine that checks for the
- // validity of items and minimizes the query. This isn't necessary for queries loaded
- // from the backend, and has the risk of removing values if they're temporarily
- // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
- model.addItems( [
- new SavedQueryItemModel(
- id,
- obj.label,
- normalizedData,
- { default: isDefault }
- )
- ] );
-
- if ( isDefault ) {
- model.default = id;
- }
+ if ( obj.data && obj.data.filters ) {
+ obj.data = model.convertToParameters( obj.data );
}
} );
- this.emit( 'initialize' );
- };
-
- /**
- * Clean up highlight parameters.
- * 'highlight' used to be stored, it's not inferred based on the presence of absence of
- * filter colors.
- *
- * @param {Object} data Saved query data
- */
- SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
- if (
- data.params.highlight === '0' &&
- data.highlights && Object.keys( data.highlights ).length
- ) {
- data.highlights = {};
- }
- delete data.params.highlight;
- };
-
- /**
- * Convert from representation of filters to representation of parameters
- *
- * @param {Object} data Query data
- * @return {Object} New converted query data
- */
- SavedQueriesModel.prototype.convertToParameters = function ( data ) {
- var newData = {},
- defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
- fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
- highlightEnabled = data.highlights.highlight;
-
- delete data.highlights.highlight;
-
- // Filters
- newData.params = this.filtersModel.getMinimizedParamRepresentation(
- this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
- );
+ this.converted = true;
+ savedQueries.version = '2';
+ }
- // Highlights: appending _color to keys
- newData.highlights = {};
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( data.highlights, function ( highlightedFilterName, value ) {
- if ( value ) {
- newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
- }
- } );
+ // Initialize the query items
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( savedQueries.queries || {}, function ( id, obj ) {
+ var normalizedData = obj.data,
+ isDefault = String( savedQueries.default ) === String( id );
- // Add highlight
- newData.params.highlight = String( Number( highlightEnabled || 0 ) );
-
- return newData;
- };
-
- /**
- * Add a query item
- *
- * @param {string} label Label for the new query
- * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
- * @param {boolean} isDefault Item is default
- * @param {string} [id] Query ID, if exists. If this isn't given, a random
- * new ID will be created.
- * @return {string} ID of the newly added query
- */
- SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
- var normalizedData = { params: {}, highlights: {} },
- highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
- randomID = String( id || ( new Date() ).getTime() ),
- data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
-
- // Split highlight/params
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( data, function ( param, value ) {
- if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
- normalizedData.highlights[ param ] = value;
- } else {
- normalizedData.params[ param ] = value;
+ if ( normalizedData && normalizedData.params ) {
+ // Backwards-compat fix: Remove sticky parameters from
+ // the given data, if they exist
+ normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
+
+ // Correct the invert state for effective selection
+ if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
+ delete normalizedData.params.invert;
}
- } );
- // Correct the invert state for effective selection
- if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
- delete normalizedData.params.invert;
+ model.cleanupHighlights( normalizedData );
+
+ id = String( id );
+
+ // Skip the addNewQuery method because we don't want to unnecessarily manipulate
+ // the given saved queries unless we literally intend to (like in backwards compat fixes)
+ // And the addNewQuery method also uses a minimization routine that checks for the
+ // validity of items and minimizes the query. This isn't necessary for queries loaded
+ // from the backend, and has the risk of removing values if they're temporarily
+ // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
+ model.addItems( [
+ new SavedQueryItemModel(
+ id,
+ obj.label,
+ normalizedData,
+ { default: isDefault }
+ )
+ ] );
+
+ if ( isDefault ) {
+ model.default = id;
+ }
}
-
- // Add item
- this.addItems( [
- new SavedQueryItemModel(
- randomID,
- label,
- normalizedData,
- { default: isDefault }
- )
- ] );
-
- if ( isDefault ) {
- this.setDefault( randomID );
+ } );
+
+ this.emit( 'initialize' );
+};
+
+/**
+ * Clean up highlight parameters.
+ * 'highlight' used to be stored, it's not inferred based on the presence of absence of
+ * filter colors.
+ *
+ * @param {Object} data Saved query data
+ */
+SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
+ if (
+ data.params.highlight === '0' &&
+ data.highlights && Object.keys( data.highlights ).length
+ ) {
+ data.highlights = {};
+ }
+ delete data.params.highlight;
+};
+
+/**
+ * Convert from representation of filters to representation of parameters
+ *
+ * @param {Object} data Query data
+ * @return {Object} New converted query data
+ */
+SavedQueriesModel.prototype.convertToParameters = function ( data ) {
+ var newData = {},
+ defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
+ fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
+ highlightEnabled = data.highlights.highlight;
+
+ delete data.highlights.highlight;
+
+ // Filters
+ newData.params = this.filtersModel.getMinimizedParamRepresentation(
+ this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
+ );
+
+ // Highlights: appending _color to keys
+ newData.highlights = {};
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( data.highlights, function ( highlightedFilterName, value ) {
+ if ( value ) {
+ newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
}
-
- return randomID;
- };
-
- /**
- * Remove query from model
- *
- * @param {string} queryID Query ID
- */
- SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
- var query = this.getItemByID( queryID );
-
- if ( query ) {
- // Check if this item was the default
- if ( String( this.getDefault() ) === String( queryID ) ) {
- // Nulify the default
- this.setDefault( null );
- }
-
- this.removeItems( [ query ] );
+ } );
+
+ // Add highlight
+ newData.params.highlight = String( Number( highlightEnabled || 0 ) );
+
+ return newData;
+};
+
+/**
+ * Add a query item
+ *
+ * @param {string} label Label for the new query
+ * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
+ * @param {boolean} isDefault Item is default
+ * @param {string} [id] Query ID, if exists. If this isn't given, a random
+ * new ID will be created.
+ * @return {string} ID of the newly added query
+ */
+SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
+ var normalizedData = { params: {}, highlights: {} },
+ highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
+ randomID = String( id || ( new Date() ).getTime() ),
+ data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
+
+ // Split highlight/params
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( data, function ( param, value ) {
+ if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
+ normalizedData.highlights[ param ] = value;
+ } else {
+ normalizedData.params[ param ] = value;
}
- };
-
- /**
- * Get an item that matches the requested query
- *
- * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
- * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
- */
- SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
- // Minimize before comparison
- fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
-
- // Correct the invert state for effective selection
- if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
- delete fullQueryComparison.invert;
+ } );
+
+ // Correct the invert state for effective selection
+ if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+ delete normalizedData.params.invert;
+ }
+
+ // Add item
+ this.addItems( [
+ new SavedQueryItemModel(
+ randomID,
+ label,
+ normalizedData,
+ { default: isDefault }
+ )
+ ] );
+
+ if ( isDefault ) {
+ this.setDefault( randomID );
+ }
+
+ return randomID;
+};
+
+/**
+ * Remove query from model
+ *
+ * @param {string} queryID Query ID
+ */
+SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
+ var query = this.getItemByID( queryID );
+
+ if ( query ) {
+ // Check if this item was the default
+ if ( String( this.getDefault() ) === String( queryID ) ) {
+ // Nulify the default
+ this.setDefault( null );
}
- return this.getItems().filter( function ( item ) {
- return OO.compare(
- item.getCombinedData(),
- fullQueryComparison
- );
- } )[ 0 ];
- };
-
- /**
- * Get query by its identifier
- *
- * @param {string} queryID Query identifier
- * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
- * the search. Undefined if not found.
- */
- SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
- return this.getItems().filter( function ( item ) {
- return item.getID() === queryID;
- } )[ 0 ];
- };
-
- /**
- * Get the full data representation of the default query, if it exists
- *
- * @return {Object|null} Representation of the default params if exists.
- * Null if default doesn't exist or if the user is not logged in.
- */
- SavedQueriesModel.prototype.getDefaultParams = function () {
- return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
- };
-
- /**
- * Get a full parameter representation of an item data
- *
- * @param {Object} queryID Query ID
- * @return {Object} Parameter representation
- */
- SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
- var item = this.getItemByID( queryID ),
- data = item ? item.getData() : {};
-
- return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
- };
-
- /**
- * Build a full parameter representation given item data and model sticky values state
- *
- * @param {Object} data Item data
- * @return {Object} Full param representation
- */
- SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
- data = data || {};
- // Return parameter representation
- return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
- data.params,
- data.highlights
- ) );
- };
-
- /**
- * Get the object representing the state of the entire model and items
- *
- * @return {Object} Object representing the state of the model and items
- */
- SavedQueriesModel.prototype.getState = function () {
- var obj = { queries: {}, version: '2' };
-
- // Translate the items to the saved object
+ this.removeItems( [ query ] );
+ }
+};
+
+/**
+ * Get an item that matches the requested query
+ *
+ * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
+ * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+ */
+SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
+ // Minimize before comparison
+ fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
+
+ // Correct the invert state for effective selection
+ if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+ delete fullQueryComparison.invert;
+ }
+
+ return this.getItems().filter( function ( item ) {
+ return OO.compare(
+ item.getCombinedData(),
+ fullQueryComparison
+ );
+ } )[ 0 ];
+};
+
+/**
+ * Get query by its identifier
+ *
+ * @param {string} queryID Query identifier
+ * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
+ * the search. Undefined if not found.
+ */
+SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
+ return this.getItems().filter( function ( item ) {
+ return item.getID() === queryID;
+ } )[ 0 ];
+};
+
+/**
+ * Get the full data representation of the default query, if it exists
+ *
+ * @return {Object|null} Representation of the default params if exists.
+ * Null if default doesn't exist or if the user is not logged in.
+ */
+SavedQueriesModel.prototype.getDefaultParams = function () {
+ return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
+};
+
+/**
+ * Get a full parameter representation of an item data
+ *
+ * @param {Object} queryID Query ID
+ * @return {Object} Parameter representation
+ */
+SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
+ var item = this.getItemByID( queryID ),
+ data = item ? item.getData() : {};
+
+ return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
+};
+
+/**
+ * Build a full parameter representation given item data and model sticky values state
+ *
+ * @param {Object} data Item data
+ * @return {Object} Full param representation
+ */
+SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
+ data = data || {};
+ // Return parameter representation
+ return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
+ data.params,
+ data.highlights
+ ) );
+};
+
+/**
+ * Get the object representing the state of the entire model and items
+ *
+ * @return {Object} Object representing the state of the model and items
+ */
+SavedQueriesModel.prototype.getState = function () {
+ var obj = { queries: {}, version: '2' };
+
+ // Translate the items to the saved object
+ this.getItems().forEach( function ( item ) {
+ obj.queries[ item.getID() ] = item.getState();
+ } );
+
+ if ( this.getDefault() ) {
+ obj.default = this.getDefault();
+ }
+
+ return obj;
+};
+
+/**
+ * Set a default query. Null to unset default.
+ *
+ * @param {string} itemID Query identifier
+ * @fires default
+ */
+SavedQueriesModel.prototype.setDefault = function ( itemID ) {
+ if ( this.default !== itemID ) {
+ this.default = itemID;
+
+ // Set for individual itens
this.getItems().forEach( function ( item ) {
- obj.queries[ item.getID() ] = item.getState();
+ item.toggleDefault( item.getID() === itemID );
} );
- if ( this.getDefault() ) {
- obj.default = this.getDefault();
- }
-
- return obj;
- };
-
- /**
- * Set a default query. Null to unset default.
- *
- * @param {string} itemID Query identifier
- * @fires default
- */
- SavedQueriesModel.prototype.setDefault = function ( itemID ) {
- if ( this.default !== itemID ) {
- this.default = itemID;
-
- // Set for individual itens
- this.getItems().forEach( function ( item ) {
- item.toggleDefault( item.getID() === itemID );
- } );
-
- this.emit( 'default', itemID );
- }
- };
-
- /**
- * Get the default query ID
- *
- * @return {string} Default query identifier
- */
- SavedQueriesModel.prototype.getDefault = function () {
- return this.default;
- };
-
- /**
- * Check if the saved queries were converted
- *
- * @return {boolean} Saved queries were converted from the previous
- * version to the new version
- */
- SavedQueriesModel.prototype.isConverted = function () {
- return this.converted;
- };
-
- module.exports = SavedQueriesModel;
-}() );
+ this.emit( 'default', itemID );
+ }
+};
+
+/**
+ * Get the default query ID
+ *
+ * @return {string} Default query identifier
+ */
+SavedQueriesModel.prototype.getDefault = function () {
+ return this.default;
+};
+
+/**
+ * Check if the saved queries were converted
+ *
+ * @return {boolean} Saved queries were converted from the previous
+ * version to the new version
+ */
+SavedQueriesModel.prototype.isConverted = function () {
+ return this.converted;
+};
+
+module.exports = SavedQueriesModel;
-( function () {
- /**
- * View model for a single saved query
- *
- * @class mw.rcfilters.dm.SavedQueryItemModel
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {string} id Unique identifier
- * @param {string} label Saved query label
- * @param {Object} data Saved query data
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [default] This item is the default
- */
- var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
- config = config || {};
-
- // Mixin constructor
- OO.EventEmitter.call( this );
-
- this.id = id;
- this.label = label;
- this.data = data;
- this.default = !!config.default;
+/**
+ * View model for a single saved query
+ *
+ * @class mw.rcfilters.dm.SavedQueryItemModel
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {string} id Unique identifier
+ * @param {string} label Saved query label
+ * @param {Object} data Saved query data
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [default] This item is the default
+ */
+var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ this.id = id;
+ this.label = label;
+ this.data = data;
+ this.default = !!config.default;
+};
+
+/* Initialization */
+
+OO.initClass( SavedQueryItemModel );
+OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );
+
+/* Events */
+
+/**
+ * @event update
+ *
+ * Model has been updated
+ */
+
+/* Methods */
+
+/**
+ * Get an object representing the state of this item
+ *
+ * @return {Object} Object representing the current data state
+ * of the object
+ */
+SavedQueryItemModel.prototype.getState = function () {
+ return {
+ data: this.getData(),
+ label: this.getLabel()
};
-
- /* Initialization */
-
- OO.initClass( SavedQueryItemModel );
- OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );
-
- /* Events */
-
- /**
- * @event update
- *
- * Model has been updated
- */
-
- /* Methods */
-
- /**
- * Get an object representing the state of this item
- *
- * @return {Object} Object representing the current data state
- * of the object
- */
- SavedQueryItemModel.prototype.getState = function () {
- return {
- data: this.getData(),
- label: this.getLabel()
- };
- };
-
- /**
- * Get the query's identifier
- *
- * @return {string} Query identifier
- */
- SavedQueryItemModel.prototype.getID = function () {
- return this.id;
- };
-
- /**
- * Get query label
- *
- * @return {string} Query label
- */
- SavedQueryItemModel.prototype.getLabel = function () {
- return this.label;
- };
-
- /**
- * Update the query label
- *
- * @param {string} newLabel New label
- */
- SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
- if ( newLabel && this.label !== newLabel ) {
- this.label = newLabel;
- this.emit( 'update' );
- }
- };
-
- /**
- * Get query data
- *
- * @return {Object} Object representing parameter and highlight data
- */
- SavedQueryItemModel.prototype.getData = function () {
- return this.data;
- };
-
- /**
- * Get the combined data of this item as a flat object of parameters
- *
- * @return {Object} Combined parameter data
- */
- SavedQueryItemModel.prototype.getCombinedData = function () {
- return $.extend( true, {}, this.data.params, this.data.highlights );
- };
-
- /**
- * Check whether this item is the default
- *
- * @return {boolean} Query is set to be default
- */
- SavedQueryItemModel.prototype.isDefault = function () {
- return this.default;
- };
-
- /**
- * Toggle the default state of this query item
- *
- * @param {boolean} isDefault Query is default
- */
- SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
- isDefault = isDefault === undefined ? !this.default : isDefault;
-
- if ( this.default !== isDefault ) {
- this.default = isDefault;
- this.emit( 'update' );
- }
- };
-
- module.exports = SavedQueryItemModel;
-}() );
+};
+
+/**
+ * Get the query's identifier
+ *
+ * @return {string} Query identifier
+ */
+SavedQueryItemModel.prototype.getID = function () {
+ return this.id;
+};
+
+/**
+ * Get query label
+ *
+ * @return {string} Query label
+ */
+SavedQueryItemModel.prototype.getLabel = function () {
+ return this.label;
+};
+
+/**
+ * Update the query label
+ *
+ * @param {string} newLabel New label
+ */
+SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
+ if ( newLabel && this.label !== newLabel ) {
+ this.label = newLabel;
+ this.emit( 'update' );
+ }
+};
+
+/**
+ * Get query data
+ *
+ * @return {Object} Object representing parameter and highlight data
+ */
+SavedQueryItemModel.prototype.getData = function () {
+ return this.data;
+};
+
+/**
+ * Get the combined data of this item as a flat object of parameters
+ *
+ * @return {Object} Combined parameter data
+ */
+SavedQueryItemModel.prototype.getCombinedData = function () {
+ return $.extend( true, {}, this.data.params, this.data.highlights );
+};
+
+/**
+ * Check whether this item is the default
+ *
+ * @return {boolean} Query is set to be default
+ */
+SavedQueryItemModel.prototype.isDefault = function () {
+ return this.default;
+};
+
+/**
+ * Toggle the default state of this query item
+ *
+ * @param {boolean} isDefault Query is default
+ */
+SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
+ isDefault = isDefault === undefined ? !this.default : isDefault;
+
+ if ( this.default !== isDefault ) {
+ this.default = isDefault;
+ this.emit( 'update' );
+ }
+};
+
+module.exports = SavedQueryItemModel;
/*!
* JavaScript for Special:RecentChanges
*/
-( function () {
-
- mw.rcfilters.HighlightColors = require( './HighlightColors.js' );
- mw.rcfilters.ui.MainWrapperWidget = require( './ui/MainWrapperWidget.js' );
-
- /**
- * Get list of namespaces and remove unused ones
- *
- * @member mw.rcfilters
- * @private
- *
- * @param {Array} unusedNamespaces Names of namespaces to remove
- * @return {Array} Filtered array of namespaces
- */
- function getNamespaces( unusedNamespaces ) {
- var i, length, name, id,
- namespaceIds = mw.config.get( 'wgNamespaceIds' ),
- namespaces = mw.config.get( 'wgFormattedNamespaces' );
-
- for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
- name = unusedNamespaces[ i ];
- id = namespaceIds[ name.toLowerCase() ];
- delete namespaces[ id ];
- }
-
- return namespaces;
+mw.rcfilters.HighlightColors = require( './HighlightColors.js' );
+mw.rcfilters.ui.MainWrapperWidget = require( './ui/MainWrapperWidget.js' );
+
+/**
+ * Get list of namespaces and remove unused ones
+ *
+ * @member mw.rcfilters
+ * @private
+ *
+ * @param {Array} unusedNamespaces Names of namespaces to remove
+ * @return {Array} Filtered array of namespaces
+ */
+function getNamespaces( unusedNamespaces ) {
+ var i, length, name, id,
+ namespaceIds = mw.config.get( 'wgNamespaceIds' ),
+ namespaces = mw.config.get( 'wgFormattedNamespaces' );
+
+ for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
+ name = unusedNamespaces[ i ];
+ id = namespaceIds[ name.toLowerCase() ];
+ delete namespaces[ id ];
}
- /**
- * @member mw.rcfilters
- * @private
- */
- function init() {
- var $topSection,
- mainWrapperWidget,
- conditionalViews = {},
- $initialFieldset = $( 'fieldset.cloptions' ),
- savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
- daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
- limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
- activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
- initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
- filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
- changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
- savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
- specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
- controller = new mw.rcfilters.Controller(
- filtersModel, changesListModel, savedQueriesModel,
- {
- savedQueriesPreferenceName: savedQueriesPreferenceName,
- daysPreferenceName: daysPreferenceName,
- limitPreferenceName: limitPreferenceName,
- collapsedPreferenceName: activeFiltersCollapsedName,
- normalizeTarget: specialPage === 'Recentchangeslinked'
- }
- );
-
- // TODO: The changesListWrapperWidget should be able to initialize
- // after the model is ready.
-
- if ( specialPage === 'Recentchanges' ) {
- $topSection = $( '.mw-recentchanges-toplinks' ).detach();
- } else if ( specialPage === 'Watchlist' ) {
- $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
- $topSection = $( '.watchlistDetails' ).detach().contents();
- } else if ( specialPage === 'Recentchangeslinked' ) {
- conditionalViews.recentChangesLinked = {
- groups: [
- {
- name: 'page',
- type: 'any_value',
- title: '',
- hidden: true,
- sticky: true,
- filters: [
- {
- name: 'target',
- default: ''
- }
- ]
- },
- {
- name: 'toOrFrom',
- type: 'boolean',
- title: '',
- hidden: true,
- sticky: true,
- filters: [
- {
- name: 'showlinkedto',
- default: false
- }
- ]
- }
- ]
- };
- }
+ return namespaces;
+}
- mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
- controller,
- filtersModel,
- savedQueriesModel,
- changesListModel,
+/**
+ * @member mw.rcfilters
+ * @private
+ */
+function init() {
+ var $topSection,
+ mainWrapperWidget,
+ conditionalViews = {},
+ $initialFieldset = $( 'fieldset.cloptions' ),
+ savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
+ daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
+ limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
+ activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
+ initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
+ filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+ changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
+ savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+ specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
+ controller = new mw.rcfilters.Controller(
+ filtersModel, changesListModel, savedQueriesModel,
{
- $wrapper: $( 'body' ),
- $topSection: $topSection,
- $filtersContainer: $( '.rcfilters-container' ),
- $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
- $formContainer: $initialFieldset,
- collapsed: initialCollapsedState
+ savedQueriesPreferenceName: savedQueriesPreferenceName,
+ daysPreferenceName: daysPreferenceName,
+ limitPreferenceName: limitPreferenceName,
+ collapsedPreferenceName: activeFiltersCollapsedName,
+ normalizeTarget: specialPage === 'Recentchangeslinked'
}
);
- // Remove the -loading class that may have been added on the server side.
- // If we are in fact going to load a default saved query, this .initialize()
- // call will do that and add the -loading class right back.
- $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-
- controller.initialize(
- mw.config.get( 'wgStructuredChangeFilters' ),
- // All namespaces without Media namespace
- getNamespaces( [ 'Media' ] ),
- require( './config.json' ).RCFiltersChangeTags,
- conditionalViews
- );
+ // TODO: The changesListWrapperWidget should be able to initialize
+ // after the model is ready.
+
+ if ( specialPage === 'Recentchanges' ) {
+ $topSection = $( '.mw-recentchanges-toplinks' ).detach();
+ } else if ( specialPage === 'Watchlist' ) {
+ $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
+ $topSection = $( '.watchlistDetails' ).detach().contents();
+ } else if ( specialPage === 'Recentchangeslinked' ) {
+ conditionalViews.recentChangesLinked = {
+ groups: [
+ {
+ name: 'page',
+ type: 'any_value',
+ title: '',
+ hidden: true,
+ sticky: true,
+ filters: [
+ {
+ name: 'target',
+ default: ''
+ }
+ ]
+ },
+ {
+ name: 'toOrFrom',
+ type: 'boolean',
+ title: '',
+ hidden: true,
+ sticky: true,
+ filters: [
+ {
+ name: 'showlinkedto',
+ default: false
+ }
+ ]
+ }
+ ]
+ };
+ }
- mainWrapperWidget.initFormWidget( specialPage );
+ mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
+ controller,
+ filtersModel,
+ savedQueriesModel,
+ changesListModel,
+ {
+ $wrapper: $( 'body' ),
+ $topSection: $topSection,
+ $filtersContainer: $( '.rcfilters-container' ),
+ $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
+ $formContainer: $initialFieldset,
+ collapsed: initialCollapsedState
+ }
+ );
- $( 'a.mw-helplink' ).attr(
- 'href',
- 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
- );
+ // Remove the -loading class that may have been added on the server side.
+ // If we are in fact going to load a default saved query, this .initialize()
+ // call will do that and add the -loading class right back.
+ $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
- controller.replaceUrl();
+ controller.initialize(
+ mw.config.get( 'wgStructuredChangeFilters' ),
+ // All namespaces without Media namespace
+ getNamespaces( [ 'Media' ] ),
+ require( './config.json' ).RCFiltersChangeTags,
+ conditionalViews
+ );
- mainWrapperWidget.setTopSection( specialPage );
+ mainWrapperWidget.initFormWidget( specialPage );
- /**
- * Fired when initialization of the filtering interface for changes list is complete.
- *
- * @event structuredChangeFilters_ui_initialized
- * @member mw.hook
- */
- mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
- }
+ $( 'a.mw-helplink' ).attr(
+ 'href',
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
+ );
- // Import i18n messages from config
- mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
+ controller.replaceUrl();
- // Early execute of init
- if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
- init();
- } else {
- $( init );
- }
+ mainWrapperWidget.setTopSection( specialPage );
+
+ /**
+ * Fired when initialization of the filtering interface for changes list is complete.
+ *
+ * @event structuredChangeFilters_ui_initialized
+ * @member mw.hook
+ */
+ mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
+}
+
+// Import i18n messages from config
+mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
- module.exports = mw.rcfilters;
+// Early execute of init
+if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
+ init();
+} else {
+ $( init );
+}
-}() );
+module.exports = mw.rcfilters;
-( function () {
- /**
- * @class
- * @singleton
- */
- mw.rcfilters = {
- Controller: require( './Controller.js' ),
- UriProcessor: require( './UriProcessor.js' ),
- dm: {
- ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
- FilterGroup: require( './dm/FilterGroup.js' ),
- FilterItem: require( './dm/FilterItem.js' ),
- FiltersViewModel: require( './dm/FiltersViewModel.js' ),
- ItemModel: require( './dm/ItemModel.js' ),
- SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
- SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
- },
- ui: {},
- utils: {
- addArrayElementsUnique: function ( arr, elements ) {
- elements = Array.isArray( elements ) ? elements : [ elements ];
-
- elements.forEach( function ( element ) {
- if ( arr.indexOf( element ) === -1 ) {
- arr.push( element );
- }
- } );
+/**
+ * @class
+ * @singleton
+ */
+mw.rcfilters = {
+ Controller: require( './Controller.js' ),
+ UriProcessor: require( './UriProcessor.js' ),
+ dm: {
+ ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
+ FilterGroup: require( './dm/FilterGroup.js' ),
+ FilterItem: require( './dm/FilterItem.js' ),
+ FiltersViewModel: require( './dm/FiltersViewModel.js' ),
+ ItemModel: require( './dm/ItemModel.js' ),
+ SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
+ SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
+ },
+ ui: {},
+ utils: {
+ addArrayElementsUnique: function ( arr, elements ) {
+ elements = Array.isArray( elements ) ? elements : [ elements ];
- return arr;
- },
- normalizeParamOptions: function ( givenOptions, legalOptions ) {
- var result = [];
-
- if ( givenOptions.indexOf( 'all' ) > -1 ) {
- // If anywhere in the values there's 'all', we
- // treat it as if only 'all' was selected.
- // Example: param=valid1,valid2,all
- // Result: param=all
- return [ 'all' ];
+ elements.forEach( function ( element ) {
+ if ( arr.indexOf( element ) === -1 ) {
+ arr.push( element );
}
+ } );
- // Get rid of any dupe and invalid parameter, only output
- // valid ones
- // Example: param=valid1,valid2,invalid1,valid1
- // Result: param=valid1,valid2
- givenOptions.forEach( function ( value ) {
- if (
- legalOptions.indexOf( value ) > -1 &&
- result.indexOf( value ) === -1
- ) {
- result.push( value );
- }
- } );
+ return arr;
+ },
+ normalizeParamOptions: function ( givenOptions, legalOptions ) {
+ var result = [];
- return result;
+ if ( givenOptions.indexOf( 'all' ) > -1 ) {
+ // If anywhere in the values there's 'all', we
+ // treat it as if only 'all' was selected.
+ // Example: param=valid1,valid2,all
+ // Result: param=all
+ return [ 'all' ];
}
+
+ // Get rid of any dupe and invalid parameter, only output
+ // valid ones
+ // Example: param=valid1,valid2,invalid1,valid1
+ // Result: param=valid1,valid2
+ givenOptions.forEach( function ( value ) {
+ if (
+ legalOptions.indexOf( value ) > -1 &&
+ result.indexOf( value ) === -1
+ ) {
+ result.push( value );
+ }
+ } );
+
+ return result;
}
- };
+ }
+};
- module.exports = mw.rcfilters;
-}() );
+module.exports = mw.rcfilters;
-( function () {
- var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
- DatePopupWidget = require( './DatePopupWidget.js' ),
- ChangesLimitAndDateButtonWidget;
-
- /**
- * Widget defining the button controlling the popup for the number of results
- *
- * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {Object} [config] Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
- config = config || {};
-
- // Parent
- ChangesLimitAndDateButtonWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = model;
-
- this.$overlay = config.$overlay || this.$element;
-
- this.button = null;
- this.limitGroupModel = null;
- this.groupByPageItemModel = null;
- this.daysGroupModel = null;
-
- this.model.connect( this, {
- initialize: 'onModelInitialize'
+var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
+ DatePopupWidget = require( './DatePopupWidget.js' ),
+ ChangesLimitAndDateButtonWidget;
+
+/**
+ * Widget defining the button controlling the popup for the number of results
+ *
+ * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
+ config = config || {};
+
+ // Parent
+ ChangesLimitAndDateButtonWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+
+ this.$overlay = config.$overlay || this.$element;
+
+ this.button = null;
+ this.limitGroupModel = null;
+ this.groupByPageItemModel = null;
+ this.daysGroupModel = null;
+
+ this.model.connect( this, {
+ initialize: 'onModelInitialize'
+ } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );
+
+/**
+ * Respond to model initialize event
+ */
+ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
+ var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
+ displayGroupModel = this.model.getGroup( 'display' );
+
+ this.limitGroupModel = this.model.getGroup( 'limit' );
+ this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
+ this.daysGroupModel = this.model.getGroup( 'days' );
+
+ // HACK: We need the model to be ready before we populate the button
+ // and the widget, because we require the filter items for the
+ // limit and their events. This addition is only done after the
+ // model is initialized.
+ // Note: This will be fixed soon!
+ if ( this.limitGroupModel && this.daysGroupModel ) {
+ changesLimitPopupWidget = new ChangesLimitPopupWidget(
+ this.limitGroupModel,
+ this.groupByPageItemModel
+ );
+
+ datePopupWidget = new DatePopupWidget(
+ this.daysGroupModel,
+ {
+ label: mw.msg( 'rcfilters-date-popup-title' )
+ }
+ );
+
+ selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
+ currentValue = ( selectedItem && selectedItem.getLabel() ) ||
+ mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
+
+ this.button = new OO.ui.PopupButtonWidget( {
+ icon: 'settings',
+ indicator: 'down',
+ label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
+ $overlay: this.$overlay,
+ popup: {
+ width: 300,
+ padded: false,
+ anchor: false,
+ align: 'backwards',
+ $autoCloseIgnore: this.$overlay,
+ $content: $( '<div>' ).append(
+ // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
+ changesLimitPopupWidget.$element,
+ datePopupWidget.$element
+ )
+ }
} );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
- };
-
- /* Initialization */
-
- OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );
-
- /**
- * Respond to model initialize event
- */
- ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
- var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
- displayGroupModel = this.model.getGroup( 'display' );
-
- this.limitGroupModel = this.model.getGroup( 'limit' );
- this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
- this.daysGroupModel = this.model.getGroup( 'days' );
-
- // HACK: We need the model to be ready before we populate the button
- // and the widget, because we require the filter items for the
- // limit and their events. This addition is only done after the
- // model is initialized.
- // Note: This will be fixed soon!
- if ( this.limitGroupModel && this.daysGroupModel ) {
- changesLimitPopupWidget = new ChangesLimitPopupWidget(
- this.limitGroupModel,
- this.groupByPageItemModel
- );
-
- datePopupWidget = new DatePopupWidget(
- this.daysGroupModel,
- {
- label: mw.msg( 'rcfilters-date-popup-title' )
- }
- );
-
- selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
- currentValue = ( selectedItem && selectedItem.getLabel() ) ||
- mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
-
- this.button = new OO.ui.PopupButtonWidget( {
- icon: 'settings',
- indicator: 'down',
- label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
- $overlay: this.$overlay,
- popup: {
- width: 300,
- padded: false,
- anchor: false,
- align: 'backwards',
- $autoCloseIgnore: this.$overlay,
- $content: $( '<div>' ).append(
- // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
- changesLimitPopupWidget.$element,
- datePopupWidget.$element
- )
- }
- } );
- this.updateButtonLabel();
-
- // Events
- this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
- this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
- changesLimitPopupWidget.connect( this, {
- limit: 'onPopupLimit',
- groupByPage: 'onPopupGroupByPage'
- } );
- datePopupWidget.connect( this, { days: 'onPopupDays' } );
-
- this.$element.append( this.button.$element );
- }
- };
-
- /**
- * Respond to popup limit change event
- *
- * @param {string} filterName Chosen filter name
- */
- ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
- var item = this.limitGroupModel.getItemByName( filterName );
-
- this.controller.toggleFilterSelect( filterName, true );
- this.controller.updateLimitDefault( item.getParamName() );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to popup limit change event
- *
- * @param {boolean} isGrouped The result set is grouped by page
- */
- ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
- this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
- this.controller.updateGroupByPageDefault( isGrouped );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to popup limit change event
- *
- * @param {string} filterName Chosen filter name
- */
- ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
- var item = this.daysGroupModel.getItemByName( filterName );
-
- this.controller.toggleFilterSelect( filterName, true );
- this.controller.updateDaysDefault( item.getParamName() );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to limit choose event
- *
- * @param {string} filterName Filter name
- */
- ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
- var message,
- limit = this.limitGroupModel.findSelectedItems()[ 0 ],
- label = limit && limit.getLabel(),
- days = this.daysGroupModel.findSelectedItems()[ 0 ],
- daysParamName = Number( days.getParamName() ) < 1 ?
- 'rcfilters-days-show-hours' :
- 'rcfilters-days-show-days';
-
- // Update the label
- if ( label && days ) {
- message = mw.msg( 'rcfilters-limit-and-date-label', label,
- mw.msg( daysParamName, days.getLabel() )
- );
- this.button.setLabel( message );
- }
- };
-
- module.exports = ChangesLimitAndDateButtonWidget;
-
-}() );
+ this.updateButtonLabel();
+
+ // Events
+ this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
+ this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
+ changesLimitPopupWidget.connect( this, {
+ limit: 'onPopupLimit',
+ groupByPage: 'onPopupGroupByPage'
+ } );
+ datePopupWidget.connect( this, { days: 'onPopupDays' } );
+
+ this.$element.append( this.button.$element );
+ }
+};
+
+/**
+ * Respond to popup limit change event
+ *
+ * @param {string} filterName Chosen filter name
+ */
+ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
+ var item = this.limitGroupModel.getItemByName( filterName );
+
+ this.controller.toggleFilterSelect( filterName, true );
+ this.controller.updateLimitDefault( item.getParamName() );
+ this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to popup limit change event
+ *
+ * @param {boolean} isGrouped The result set is grouped by page
+ */
+ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
+ this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
+ this.controller.updateGroupByPageDefault( isGrouped );
+ this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to popup limit change event
+ *
+ * @param {string} filterName Chosen filter name
+ */
+ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
+ var item = this.daysGroupModel.getItemByName( filterName );
+
+ this.controller.toggleFilterSelect( filterName, true );
+ this.controller.updateDaysDefault( item.getParamName() );
+ this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to limit choose event
+ *
+ * @param {string} filterName Filter name
+ */
+ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
+ var message,
+ limit = this.limitGroupModel.findSelectedItems()[ 0 ],
+ label = limit && limit.getLabel(),
+ days = this.daysGroupModel.findSelectedItems()[ 0 ],
+ daysParamName = Number( days.getParamName() ) < 1 ?
+ 'rcfilters-days-show-hours' :
+ 'rcfilters-days-show-days';
+
+ // Update the label
+ if ( label && days ) {
+ message = mw.msg( 'rcfilters-limit-and-date-label', label,
+ mw.msg( daysParamName, days.getLabel() )
+ );
+ this.button.setLabel( message );
+ }
+};
+
+module.exports = ChangesLimitAndDateButtonWidget;
-( function () {
- var ValuePickerWidget = require( './ValuePickerWidget.js' ),
- ChangesLimitPopupWidget;
+var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+ ChangesLimitPopupWidget;
- /**
- * Widget defining the popup to choose number of results
- *
- * @class mw.rcfilters.ui.ChangesLimitPopupWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
- * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
- * @param {Object} [config] Configuration object
- */
- ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
- config = config || {};
+/**
+ * Widget defining the popup to choose number of results
+ *
+ * @class mw.rcfilters.ui.ChangesLimitPopupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
+ * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
+ * @param {Object} [config] Configuration object
+ */
+ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
+ config = config || {};
- // Parent
- ChangesLimitPopupWidget.parent.call( this, config );
+ // Parent
+ ChangesLimitPopupWidget.parent.call( this, config );
- this.limitModel = limitModel;
- this.groupByPageItemModel = groupByPageItemModel;
+ this.limitModel = limitModel;
+ this.groupByPageItemModel = groupByPageItemModel;
- this.valuePicker = new ValuePickerWidget(
- this.limitModel,
- {
- label: mw.msg( 'rcfilters-limit-title' )
- }
- );
+ this.valuePicker = new ValuePickerWidget(
+ this.limitModel,
+ {
+ label: mw.msg( 'rcfilters-limit-title' )
+ }
+ );
- this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
- selected: this.groupByPageItemModel.isSelected()
- } );
+ this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
+ selected: this.groupByPageItemModel.isSelected()
+ } );
- // Events
- this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
- this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
- this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
+ // Events
+ this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
+ this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
+ this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
- .append(
- this.valuePicker.$element,
- new OO.ui.FieldLayout(
- this.groupByPageCheckbox,
- {
- align: 'inline',
- label: mw.msg( 'rcfilters-group-results-by-page' )
- }
- ).$element
- );
- };
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
+ .append(
+ this.valuePicker.$element,
+ new OO.ui.FieldLayout(
+ this.groupByPageCheckbox,
+ {
+ align: 'inline',
+ label: mw.msg( 'rcfilters-group-results-by-page' )
+ }
+ ).$element
+ );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
+OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
- /* Events */
+/* Events */
- /**
- * @event limit
- * @param {string} name Item name
- *
- * A limit item was chosen
- */
+/**
+ * @event limit
+ * @param {string} name Item name
+ *
+ * A limit item was chosen
+ */
- /**
- * @event groupByPage
- * @param {boolean} isGrouped The results are grouped by page
- *
- * Results are grouped by page
- */
+/**
+ * @event groupByPage
+ * @param {boolean} isGrouped The results are grouped by page
+ *
+ * Results are grouped by page
+ */
- /**
- * Respond to group by page model update
- */
- ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
- this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
- };
+/**
+ * Respond to group by page model update
+ */
+ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
+ this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
+};
- module.exports = ChangesLimitPopupWidget;
-}() );
+module.exports = ChangesLimitPopupWidget;
-( function () {
- /**
- * List of changes
- *
- * @class mw.rcfilters.ui.ChangesListWrapperWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
- * @param {mw.rcfilters.Controller} controller
- * @param {jQuery} $changesListRoot Root element of the changes list to attach to
- * @param {Object} [config] Configuration object
- */
- var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
- filtersViewModel,
- changesListViewModel,
- controller,
- $changesListRoot,
- config
- ) {
- config = $.extend( {}, config, {
- $element: $changesListRoot
- } );
-
- // Parent
- ChangesListWrapperWidget.parent.call( this, config );
-
- this.filtersViewModel = filtersViewModel;
- this.changesListViewModel = changesListViewModel;
- this.controller = controller;
- this.highlightClasses = null;
-
- // Events
- this.filtersViewModel.connect( this, {
- itemUpdate: 'onItemUpdate',
- highlightChange: 'onHighlightChange'
- } );
- this.changesListViewModel.connect( this, {
- invalidate: 'onModelInvalidate',
- update: 'onModelUpdate'
- } );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
- // We handle our own display/hide of the empty results message
- // We keep the timeout class here and remove it later, since at this
- // stage it is still needed to identify that the timeout occurred.
- .removeClass( 'mw-changeslist-empty' );
- };
-
- /* Initialization */
-
- OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );
-
- /**
- * Get all available highlight classes
- *
- * @return {string[]} An array of available highlight class names
- */
- ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
- if ( !this.highlightClasses || !this.highlightClasses.length ) {
- this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
- .map( function ( filterItem ) {
- return filterItem.getCssClass();
- } );
- }
-
- return this.highlightClasses;
- };
-
- /**
- * Respond to the highlight feature being toggled on and off
- *
- * @param {boolean} highlightEnabled
- */
- ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
- if ( highlightEnabled ) {
- this.applyHighlight();
+/**
+ * List of changes
+ *
+ * @class mw.rcfilters.ui.ChangesListWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
+ * @param {mw.rcfilters.Controller} controller
+ * @param {jQuery} $changesListRoot Root element of the changes list to attach to
+ * @param {Object} [config] Configuration object
+ */
+var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
+ filtersViewModel,
+ changesListViewModel,
+ controller,
+ $changesListRoot,
+ config
+) {
+ config = $.extend( {}, config, {
+ $element: $changesListRoot
+ } );
+
+ // Parent
+ ChangesListWrapperWidget.parent.call( this, config );
+
+ this.filtersViewModel = filtersViewModel;
+ this.changesListViewModel = changesListViewModel;
+ this.controller = controller;
+ this.highlightClasses = null;
+
+ // Events
+ this.filtersViewModel.connect( this, {
+ itemUpdate: 'onItemUpdate',
+ highlightChange: 'onHighlightChange'
+ } );
+ this.changesListViewModel.connect( this, {
+ invalidate: 'onModelInvalidate',
+ update: 'onModelUpdate'
+ } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
+ // We handle our own display/hide of the empty results message
+ // We keep the timeout class here and remove it later, since at this
+ // stage it is still needed to identify that the timeout occurred.
+ .removeClass( 'mw-changeslist-empty' );
+};
+
+/* Initialization */
+
+OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );
+
+/**
+ * Get all available highlight classes
+ *
+ * @return {string[]} An array of available highlight class names
+ */
+ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
+ if ( !this.highlightClasses || !this.highlightClasses.length ) {
+ this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
+ .map( function ( filterItem ) {
+ return filterItem.getCssClass();
+ } );
+ }
+
+ return this.highlightClasses;
+};
+
+/**
+ * Respond to the highlight feature being toggled on and off
+ *
+ * @param {boolean} highlightEnabled
+ */
+ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
+ if ( highlightEnabled ) {
+ this.applyHighlight();
+ } else {
+ this.clearHighlight();
+ }
+};
+
+/**
+ * Respond to a filter item model update
+ */
+ChangesListWrapperWidget.prototype.onItemUpdate = function () {
+ if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
+ // this.controller.isInitialized() is still false during page load,
+ // we don't want to clear/apply highlights at this stage.
+ this.clearHighlight();
+ this.applyHighlight();
+ }
+};
+
+/**
+ * Respond to changes list model invalidate
+ */
+ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
+ $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
+};
+
+/**
+ * Respond to changes list model update
+ *
+ * @param {jQuery|string} $changesListContent The content of the updated changes list
+ * @param {jQuery} $fieldset The content of the updated fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+ * @param {boolean} from Timestamp of the new changes
+ */
+ChangesListWrapperWidget.prototype.onModelUpdate = function (
+ $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
+) {
+ var conflictItem,
+ $message = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
+ isEmpty = $changesListContent === 'NO_RESULTS',
+ // For enhanced mode, we have to load these modules, which are
+ // not loaded for the 'regular' mode in the backend
+ loaderPromise = mw.user.options.get( 'usenewrc' ) ?
+ mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
+ $.Deferred().resolve(),
+ widget = this;
+
+ this.$element.toggleClass( 'mw-changeslist', !isEmpty );
+ if ( isEmpty ) {
+ this.$element.empty();
+
+ if ( this.filtersViewModel.hasConflict() ) {
+ conflictItem = this.filtersViewModel.getFirstConflictedItem();
+
+ $message
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
+ .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
+ .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
+ );
} else {
- this.clearHighlight();
- }
- };
+ $message
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
+ .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
+ );
- /**
- * Respond to a filter item model update
- */
- ChangesListWrapperWidget.prototype.onItemUpdate = function () {
- if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
- // this.controller.isInitialized() is still false during page load,
- // we don't want to clear/apply highlights at this stage.
- this.clearHighlight();
- this.applyHighlight();
+ // remove all classes matching mw-changeslist-*
+ this.$element.removeClass( function ( elementIndex, allClasses ) {
+ return allClasses
+ .split( ' ' )
+ .filter( function ( className ) {
+ return className.indexOf( 'mw-changeslist-' ) === 0;
+ } )
+ .join( ' ' );
+ } );
}
- };
- /**
- * Respond to changes list model invalidate
- */
- ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
- $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
- };
+ this.$element.append( $message );
+ } else {
+ if ( !isInitialDOM ) {
+ this.$element.empty().append( $changesListContent );
- /**
- * Respond to changes list model update
- *
- * @param {jQuery|string} $changesListContent The content of the updated changes list
- * @param {jQuery} $fieldset The content of the updated fieldset
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
- * @param {boolean} from Timestamp of the new changes
- */
- ChangesListWrapperWidget.prototype.onModelUpdate = function (
- $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
- ) {
- var conflictItem,
- $message = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
- isEmpty = $changesListContent === 'NO_RESULTS',
- // For enhanced mode, we have to load these modules, which are
- // not loaded for the 'regular' mode in the backend
- loaderPromise = mw.user.options.get( 'usenewrc' ) ?
- mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
- $.Deferred().resolve(),
- widget = this;
-
- this.$element.toggleClass( 'mw-changeslist', !isEmpty );
- if ( isEmpty ) {
- this.$element.empty();
-
- if ( this.filtersViewModel.hasConflict() ) {
- conflictItem = this.filtersViewModel.getFirstConflictedItem();
-
- $message
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
- .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
- .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
- );
- } else {
- $message
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
- .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
- );
-
- // remove all classes matching mw-changeslist-*
- this.$element.removeClass( function ( elementIndex, allClasses ) {
- return allClasses
- .split( ' ' )
- .filter( function ( className ) {
- return className.indexOf( 'mw-changeslist-' ) === 0;
- } )
- .join( ' ' );
- } );
+ if ( from ) {
+ this.emphasizeNewChanges( from );
}
-
- this.$element.append( $message );
- } else {
- if ( !isInitialDOM ) {
- this.$element.empty().append( $changesListContent );
-
- if ( from ) {
- this.emphasizeNewChanges( from );
- }
- }
-
- // Apply highlight
- this.applyHighlight();
-
}
- this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
+ // Apply highlight
+ this.applyHighlight();
- loaderPromise.done( function () {
- if ( !isInitialDOM && !isEmpty ) {
- // Make sure enhanced RC re-initializes correctly
- mw.hook( 'wikipage.content' ).fire( widget.$element );
- }
+ }
- $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
- } );
- };
+ this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
- /** Toggles overlay class on changes list
- *
- * @param {boolean} isVisible True if overlay should be visible
- */
- ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
- this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
- };
+ loaderPromise.done( function () {
+ if ( !isInitialDOM && !isEmpty ) {
+ // Make sure enhanced RC re-initializes correctly
+ mw.hook( 'wikipage.content' ).fire( widget.$element );
+ }
- /**
- * Map a reason for having no results to its message key
- *
- * @param {string} reason One of the NO_RESULTS_* "constant" that represent
- * a reason for having no results
- * @return {string} Key for the message that explains why there is no results in this case
- */
- ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
- var reasonMsgKeyMap = {
- NO_RESULTS_NORMAL: 'recentchanges-noresult',
- NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
- NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
- NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
- NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
- };
- return reasonMsgKeyMap[ reason ];
+ $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
+ } );
+};
+
+/** Toggles overlay class on changes list
+ *
+ * @param {boolean} isVisible True if overlay should be visible
+ */
+ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
+ this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
+};
+
+/**
+ * Map a reason for having no results to its message key
+ *
+ * @param {string} reason One of the NO_RESULTS_* "constant" that represent
+ * a reason for having no results
+ * @return {string} Key for the message that explains why there is no results in this case
+ */
+ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
+ var reasonMsgKeyMap = {
+ NO_RESULTS_NORMAL: 'recentchanges-noresult',
+ NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
+ NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
+ NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
+ NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
};
-
- /**
- * Emphasize the elements (or groups) newer than the 'from' parameter
- * @param {string} from Anything newer than this is considered 'new'
- */
- ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
- var $firstNew,
- $indicator,
- $newChanges = $( [] ),
- selector = this.inEnhancedMode() ?
- 'table.mw-enhanced-rc[data-mw-ts]' :
- 'li[data-mw-ts]',
- set = this.$element.find( selector ),
- length = set.length;
-
- set.each( function ( index ) {
- var $this = $( this ),
- ts = $this.data( 'mw-ts' );
-
- if ( ts >= from ) {
- $newChanges = $newChanges.add( $this );
- $firstNew = $this;
-
- // guards against putting the marker after the last element
- if ( index === ( length - 1 ) ) {
- $firstNew = null;
- }
+ return reasonMsgKeyMap[ reason ];
+};
+
+/**
+ * Emphasize the elements (or groups) newer than the 'from' parameter
+ * @param {string} from Anything newer than this is considered 'new'
+ */
+ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
+ var $firstNew,
+ $indicator,
+ $newChanges = $( [] ),
+ selector = this.inEnhancedMode() ?
+ 'table.mw-enhanced-rc[data-mw-ts]' :
+ 'li[data-mw-ts]',
+ set = this.$element.find( selector ),
+ length = set.length;
+
+ set.each( function ( index ) {
+ var $this = $( this ),
+ ts = $this.data( 'mw-ts' );
+
+ if ( ts >= from ) {
+ $newChanges = $newChanges.add( $this );
+ $firstNew = $this;
+
+ // guards against putting the marker after the last element
+ if ( index === ( length - 1 ) ) {
+ $firstNew = null;
}
- } );
-
- if ( $firstNew ) {
- $indicator = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
-
- $firstNew.after( $indicator );
}
-
- // FIXME: Use CSS transition
- // eslint-disable-next-line no-jquery/no-fade
- $newChanges
- .hide()
- .fadeIn( 1000 );
- };
-
- /**
- * In enhanced mode, we need to check whether the grouped results all have the
- * same active highlights in order to see whether the "parent" of the group should
- * be grey or highlighted normally.
- *
- * This is called every time highlights are applied.
- */
- ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
- var activeHighlightClasses,
- $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
-
- activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
- return 'mw-rcfilters-highlight-color-' + color;
+ } );
+
+ if ( $firstNew ) {
+ $indicator = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
+
+ $firstNew.after( $indicator );
+ }
+
+ // FIXME: Use CSS transition
+ // eslint-disable-next-line no-jquery/no-fade
+ $newChanges
+ .hide()
+ .fadeIn( 1000 );
+};
+
+/**
+ * In enhanced mode, we need to check whether the grouped results all have the
+ * same active highlights in order to see whether the "parent" of the group should
+ * be grey or highlighted normally.
+ *
+ * This is called every time highlights are applied.
+ */
+ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
+ var activeHighlightClasses,
+ $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
+
+ activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
+ return 'mw-rcfilters-highlight-color-' + color;
+ } );
+
+ // Go over top pages and their children, and figure out if all sub-pages have the
+ // same highlights between themselves. If they do, the parent should be highlighted
+ // with all colors. If classes are different, the parent should receive a grey
+ // background
+ $enhancedTopPageCell.each( function () {
+ var firstChildClasses, $rowsWithDifferentHighlights,
+ $table = $( this );
+
+ // Collect the relevant classes from the first nested child
+ firstChildClasses = activeHighlightClasses.filter( function ( className ) {
+ return $table.find( 'tr:nth-child(2)' ).hasClass( className );
} );
-
- // Go over top pages and their children, and figure out if all sub-pages have the
- // same highlights between themselves. If they do, the parent should be highlighted
- // with all colors. If classes are different, the parent should receive a grey
- // background
- $enhancedTopPageCell.each( function () {
- var firstChildClasses, $rowsWithDifferentHighlights,
- $table = $( this );
-
- // Collect the relevant classes from the first nested child
- firstChildClasses = activeHighlightClasses.filter( function ( className ) {
- return $table.find( 'tr:nth-child(2)' ).hasClass( className );
+ // Filter the non-head rows and see if they all have the same classes
+ // to the first row
+ $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
+ var classesInThisRow,
+ $this = $( this );
+
+ classesInThisRow = activeHighlightClasses.filter( function ( className ) {
+ return $this.hasClass( className );
} );
- // Filter the non-head rows and see if they all have the same classes
- // to the first row
- $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
- var classesInThisRow,
- $this = $( this );
- classesInThisRow = activeHighlightClasses.filter( function ( className ) {
- return $this.hasClass( className );
- } );
-
- return !OO.compare( firstChildClasses, classesInThisRow );
- } );
-
- // If classes are different, tag the row for using grey color
- $table.find( 'tr:first-child' )
- .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
+ return !OO.compare( firstChildClasses, classesInThisRow );
} );
- };
-
- /**
- * @return {boolean} Whether the changes are grouped by page
- */
- ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
- var uri = new mw.Uri();
- return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
- ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
- };
-
- /**
- * Apply color classes based on filters highlight configuration
- */
- ChangesListWrapperWidget.prototype.applyHighlight = function () {
- if ( !this.filtersViewModel.isHighlightEnabled() ) {
- return;
- }
-
- this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
- var $elements = this.$element.find( '.' + filterItem.getCssClass() );
- // Add highlight class to all highlighted list items
- $elements
- .addClass(
- 'mw-rcfilters-highlighted ' +
- 'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
- );
-
- // Track the filters for each item in .data( 'highlightedFilters' )
- $elements.each( function () {
- var filters = $( this ).data( 'highlightedFilters' );
- if ( !filters ) {
- filters = [];
- $( this ).data( 'highlightedFilters', filters );
- }
- if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
- filters.push( filterItem.getLabel() );
- }
- } );
- }.bind( this ) );
- // Apply a title to each highlighted item, with a list of filters
- this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
+ // If classes are different, tag the row for using grey color
+ $table.find( 'tr:first-child' )
+ .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
+ } );
+};
+
+/**
+ * @return {boolean} Whether the changes are grouped by page
+ */
+ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
+ var uri = new mw.Uri();
+ return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
+ ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
+};
+
+/**
+ * Apply color classes based on filters highlight configuration
+ */
+ChangesListWrapperWidget.prototype.applyHighlight = function () {
+ if ( !this.filtersViewModel.isHighlightEnabled() ) {
+ return;
+ }
+
+ this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
+ var $elements = this.$element.find( '.' + filterItem.getCssClass() );
+
+ // Add highlight class to all highlighted list items
+ $elements
+ .addClass(
+ 'mw-rcfilters-highlighted ' +
+ 'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
+ );
+
+ // Track the filters for each item in .data( 'highlightedFilters' )
+ $elements.each( function () {
var filters = $( this ).data( 'highlightedFilters' );
-
- if ( filters && filters.length ) {
- $( this ).attr( 'title', mw.msg(
- 'rcfilters-highlighted-filters-list',
- filters.join( mw.msg( 'comma-separator' ) )
- ) );
+ if ( !filters ) {
+ filters = [];
+ $( this ).data( 'highlightedFilters', filters );
+ }
+ if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
+ filters.push( filterItem.getLabel() );
}
-
} );
- if ( this.inEnhancedMode() ) {
- this.updateEnhancedParentHighlight();
+ }.bind( this ) );
+ // Apply a title to each highlighted item, with a list of filters
+ this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
+ var filters = $( this ).data( 'highlightedFilters' );
+
+ if ( filters && filters.length ) {
+ $( this ).attr( 'title', mw.msg(
+ 'rcfilters-highlighted-filters-list',
+ filters.join( mw.msg( 'comma-separator' ) )
+ ) );
}
- // Turn on highlights
- this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
- };
+ } );
+ if ( this.inEnhancedMode() ) {
+ this.updateEnhancedParentHighlight();
+ }
+
+ // Turn on highlights
+ this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+};
+
+/**
+ * Remove all color classes
+ */
+ChangesListWrapperWidget.prototype.clearHighlight = function () {
+ // Remove highlight classes
+ mw.rcfilters.HighlightColors.forEach( function ( color ) {
+ this.$element
+ .find( '.mw-rcfilters-highlight-color-' + color )
+ .removeClass( 'mw-rcfilters-highlight-color-' + color );
+ }.bind( this ) );
- /**
- * Remove all color classes
- */
- ChangesListWrapperWidget.prototype.clearHighlight = function () {
- // Remove highlight classes
- mw.rcfilters.HighlightColors.forEach( function ( color ) {
- this.$element
- .find( '.mw-rcfilters-highlight-color-' + color )
- .removeClass( 'mw-rcfilters-highlight-color-' + color );
- }.bind( this ) );
-
- this.$element.find( '.mw-rcfilters-highlighted' )
- .removeAttr( 'title' )
- .removeData( 'highlightedFilters' )
- .removeClass( 'mw-rcfilters-highlighted' );
-
- // Remove grey from enhanced rows
- this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
- .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
-
- // Turn off highlights
- this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
- };
+ this.$element.find( '.mw-rcfilters-highlighted' )
+ .removeAttr( 'title' )
+ .removeData( 'highlightedFilters' )
+ .removeClass( 'mw-rcfilters-highlighted' );
+
+ // Remove grey from enhanced rows
+ this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
+ .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
+
+ // Turn off highlights
+ this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+};
- module.exports = ChangesListWrapperWidget;
-}() );
+module.exports = ChangesListWrapperWidget;
-( function () {
- /**
- * A widget representing a single toggle filter
- *
- * @class mw.rcfilters.ui.CheckboxInputWidget
- * @extends OO.ui.CheckboxInputWidget
- *
- * @constructor
- * @param {Object} config Configuration object
- */
- var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
- config = config || {};
+/**
+ * A widget representing a single toggle filter
+ *
+ * @class mw.rcfilters.ui.CheckboxInputWidget
+ * @extends OO.ui.CheckboxInputWidget
+ *
+ * @constructor
+ * @param {Object} config Configuration object
+ */
+var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
+ config = config || {};
- // Parent
- CheckboxInputWidget.parent.call( this, config );
+ // Parent
+ CheckboxInputWidget.parent.call( this, config );
- // Event
- this.$input
- // HACK: This widget just pretends to be a checkbox for visual purposes.
- // In reality, all actions - setting to true or false, etc - are
- // decided by the model, and executed by the controller. This means
- // that we want to let the controller and model make the decision
- // of whether to check/uncheck this checkboxInputWidget, and for that,
- // we have to bypass the browser action that checks/unchecks it during
- // click.
- .on( 'click', false )
- .on( 'change', this.onUserChange.bind( this ) );
- };
+ // Event
+ this.$input
+ // HACK: This widget just pretends to be a checkbox for visual purposes.
+ // In reality, all actions - setting to true or false, etc - are
+ // decided by the model, and executed by the controller. This means
+ // that we want to let the controller and model make the decision
+ // of whether to check/uncheck this checkboxInputWidget, and for that,
+ // we have to bypass the browser action that checks/unchecks it during
+ // click.
+ .on( 'click', false )
+ .on( 'change', this.onUserChange.bind( this ) );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
+OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
- /* Events */
+/* Events */
- /**
- * @event userChange
- * @param {boolean} Current state of the checkbox
- *
- * The user has checked or unchecked this checkbox
- */
+/**
+ * @event userChange
+ * @param {boolean} Current state of the checkbox
+ *
+ * The user has checked or unchecked this checkbox
+ */
- /* Methods */
+/* Methods */
- /**
- * @inheritdoc
- */
- CheckboxInputWidget.prototype.onEdit = function () {
- // Similarly to preventing defaults in 'click' event, we want
- // to prevent this widget from deciding anything about its own
- // state; it emits a change event and the model and controller
- // make a decision about what its select state is.
- // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
- // so we really want to prevent that from messing with what
- // the model decides the state of the widget is.
- };
+/**
+ * @inheritdoc
+ */
+CheckboxInputWidget.prototype.onEdit = function () {
+ // Similarly to preventing defaults in 'click' event, we want
+ // to prevent this widget from deciding anything about its own
+ // state; it emits a change event and the model and controller
+ // make a decision about what its select state is.
+ // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
+ // so we really want to prevent that from messing with what
+ // the model decides the state of the widget is.
+};
- /**
- * Respond to checkbox change by a user and emit 'userChange'.
- */
- CheckboxInputWidget.prototype.onUserChange = function () {
- this.emit( 'userChange', this.$input.prop( 'checked' ) );
- };
+/**
+ * Respond to checkbox change by a user and emit 'userChange'.
+ */
+CheckboxInputWidget.prototype.onUserChange = function () {
+ this.emit( 'userChange', this.$input.prop( 'checked' ) );
+};
- module.exports = CheckboxInputWidget;
-}() );
+module.exports = CheckboxInputWidget;
-( function () {
- var ValuePickerWidget = require( './ValuePickerWidget.js' ),
- DatePopupWidget;
+var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+ DatePopupWidget;
- /**
- * Widget defining the popup to choose date for the results
- *
- * @class mw.rcfilters.ui.DatePopupWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
- * @param {Object} [config] Configuration object
- */
- DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
- config = config || {};
+/**
+ * Widget defining the popup to choose date for the results
+ *
+ * @class mw.rcfilters.ui.DatePopupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
+ * @param {Object} [config] Configuration object
+ */
+DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
+ config = config || {};
- // Parent
- DatePopupWidget.parent.call( this, config );
- // Mixin constructors
- OO.ui.mixin.LabelElement.call( this, config );
+ // Parent
+ DatePopupWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.LabelElement.call( this, config );
- this.model = model;
+ this.model = model;
- this.hoursValuePicker = new ValuePickerWidget(
- this.model,
- {
- classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
- label: mw.msg( 'rcfilters-hours-title' ),
- itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
- }
- );
- this.daysValuePicker = new ValuePickerWidget(
- this.model,
- {
- classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
- label: mw.msg( 'rcfilters-days-title' ),
- itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
- }
- );
+ this.hoursValuePicker = new ValuePickerWidget(
+ this.model,
+ {
+ classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
+ label: mw.msg( 'rcfilters-hours-title' ),
+ itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
+ }
+ );
+ this.daysValuePicker = new ValuePickerWidget(
+ this.model,
+ {
+ classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
+ label: mw.msg( 'rcfilters-days-title' ),
+ itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
+ }
+ );
- // Events
- this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
- this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+ // Events
+ this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+ this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-datePopupWidget' )
- .append(
- this.$label
- .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
- this.hoursValuePicker.$element,
- this.daysValuePicker.$element
- );
- };
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-datePopupWidget' )
+ .append(
+ this.$label
+ .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
+ this.hoursValuePicker.$element,
+ this.daysValuePicker.$element
+ );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( DatePopupWidget, OO.ui.Widget );
- OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
+OO.inheritClass( DatePopupWidget, OO.ui.Widget );
+OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
- /* Events */
+/* Events */
- /**
- * @event days
- * @param {string} name Item name
- *
- * A days item was chosen
- */
+/**
+ * @event days
+ * @param {string} name Item name
+ *
+ * A days item was chosen
+ */
- module.exports = DatePopupWidget;
-}() );
+module.exports = DatePopupWidget;
-( function () {
- /**
- * A button to configure highlight for a filter item
- *
- * @class mw.rcfilters.ui.FilterItemHighlightButton
- * @extends OO.ui.PopupButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FilterItem} model Filter item model
- * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
- * @param {Object} [config] Configuration object
- */
- var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
- config = config || {};
-
- // Parent
- FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
- icon: 'highlight',
- indicator: 'down'
- } ) );
-
- this.controller = controller;
- this.model = model;
- this.popup = highlightPopup;
-
- // Event
- this.model.connect( this, { update: 'updateUiBasedOnModel' } );
- // This lives inside a MenuOptionWidget, which intercepts mousedown
- // to select the item. We want to prevent that when we click the highlight
- // button
- this.$element.on( 'mousedown', function ( e ) {
- e.stopPropagation();
- } );
-
- this.updateUiBasedOnModel();
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
- };
-
- /* Initialization */
-
- OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );
-
- /* Static Properties */
-
- /**
- * @static
- */
- FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
-
- /* Methods */
-
- FilterItemHighlightButton.prototype.onAction = function () {
- this.popup.setAssociatedButton( this );
- this.popup.setFilterItem( this.model );
-
- // Parent method
- FilterItemHighlightButton.parent.prototype.onAction.call( this );
- };
-
- /**
- * Respond to item model update event
- */
- FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
- var currentColor = this.model.getHighlightColor(),
- widget = this;
-
- this.$icon.toggleClass(
- 'mw-rcfilters-ui-filterItemHighlightButton-circle',
- currentColor !== null
- );
-
- mw.rcfilters.HighlightColors.forEach( function ( c ) {
- widget.$icon
- .toggleClass(
- 'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
- c === currentColor
- );
- } );
- };
-
- module.exports = FilterItemHighlightButton;
-}() );
+/**
+ * A button to configure highlight for a filter item
+ *
+ * @class mw.rcfilters.ui.FilterItemHighlightButton
+ * @extends OO.ui.PopupButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FilterItem} model Filter item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+ * @param {Object} [config] Configuration object
+ */
+var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
+ config = config || {};
+
+ // Parent
+ FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
+ icon: 'highlight',
+ indicator: 'down'
+ } ) );
+
+ this.controller = controller;
+ this.model = model;
+ this.popup = highlightPopup;
+
+ // Event
+ this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+ // This lives inside a MenuOptionWidget, which intercepts mousedown
+ // to select the item. We want to prevent that when we click the highlight
+ // button
+ this.$element.on( 'mousedown', function ( e ) {
+ e.stopPropagation();
+ } );
+
+ this.updateUiBasedOnModel();
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );
+
+/* Static Properties */
+
+/**
+ * @static
+ */
+FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
+
+/* Methods */
+
+FilterItemHighlightButton.prototype.onAction = function () {
+ this.popup.setAssociatedButton( this );
+ this.popup.setFilterItem( this.model );
+
+ // Parent method
+ FilterItemHighlightButton.parent.prototype.onAction.call( this );
+};
+
+/**
+ * Respond to item model update event
+ */
+FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
+ var currentColor = this.model.getHighlightColor(),
+ widget = this;
+
+ this.$icon.toggleClass(
+ 'mw-rcfilters-ui-filterItemHighlightButton-circle',
+ currentColor !== null
+ );
+
+ mw.rcfilters.HighlightColors.forEach( function ( c ) {
+ widget.$icon
+ .toggleClass(
+ 'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
+ c === currentColor
+ );
+ } );
+};
+
+module.exports = FilterItemHighlightButton;
-( function () {
- /**
- * Menu header for the RCFilters filters menu
- *
- * @class mw.rcfilters.ui.FilterMenuHeaderWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {Object} config Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.$overlay = config.$overlay || this.$element;
-
- // Parent
- FilterMenuHeaderWidget.parent.call( this, config );
- OO.ui.mixin.LabelElement.call( this, $.extend( {
- label: mw.msg( 'rcfilters-filterlist-title' ),
- $label: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
- }, config ) );
-
- // "Back" to default view button
- this.backButton = new OO.ui.ButtonWidget( {
- icon: 'previous',
- framed: false,
- title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
- } );
- this.backButton.toggle( this.model.getCurrentView() !== 'default' );
-
- // Help icon for Tagged edits
- this.helpIcon = new OO.ui.ButtonWidget( {
- icon: 'helpNotice',
- framed: false,
- title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
- href: mw.util.getUrl( 'Special:Tags' ),
- target: '_blank'
- } );
- this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
-
- // Highlight button
- this.highlightButton = new OO.ui.ToggleButtonWidget( {
- icon: 'highlight',
- label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
- } );
-
- // Invert namespaces button
- this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
- icon: '',
- classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
- } );
- this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
-
- // Events
- this.backButton.connect( this, { click: 'onBackButtonClick' } );
- this.highlightButton
- .connect( this, { click: 'onHighlightButtonClick' } );
- this.invertNamespacesButton
- .connect( this, { click: 'onInvertNamespacesButtonClick' } );
- this.model.connect( this, {
- highlightChange: 'onModelHighlightChange',
- searchChange: 'onModelSearchChange',
- initialize: 'onModelInitialize'
- } );
- this.view = this.model.getCurrentView();
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
- .append( this.backButton.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
- .append( this.$label, this.helpIcon.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
- .append( this.invertNamespacesButton.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
- .append( this.highlightButton.$element )
- )
- )
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
- OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
-
- /* Methods */
-
- /**
- * Respond to model initialization event
- *
- * Note: need to wait for initialization before getting the invertModel
- * and registering its update event. Creating all the models before the UI
- * would help with that.
- */
- FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
- this.invertModel = this.model.getInvertModel();
- this.updateInvertButton();
- this.invertModel.connect( this, { update: 'updateInvertButton' } );
- };
-
- /**
- * Respond to model update event
- */
- FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
- var currentView = this.model.getCurrentView();
-
- if ( this.view !== currentView ) {
- this.setLabel( this.model.getViewTitle( currentView ) );
-
- this.invertNamespacesButton.toggle( currentView === 'namespaces' );
- this.backButton.toggle( currentView !== 'default' );
- this.helpIcon.toggle( currentView === 'tags' );
- this.view = currentView;
- }
- };
-
- /**
- * Respond to model highlight change event
- *
- * @param {boolean} highlightEnabled Highlight is enabled
- */
- FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
- this.highlightButton.setActive( highlightEnabled );
- };
-
- /**
- * Update the state of the invert button
- */
- FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
- this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
- this.invertNamespacesButton.setLabel(
- this.invertModel.isSelected() ?
- mw.msg( 'rcfilters-exclude-button-on' ) :
- mw.msg( 'rcfilters-exclude-button-off' )
+/**
+ * Menu header for the RCFilters filters menu
+ *
+ * @class mw.rcfilters.ui.FilterMenuHeaderWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.$overlay = config.$overlay || this.$element;
+
+ // Parent
+ FilterMenuHeaderWidget.parent.call( this, config );
+ OO.ui.mixin.LabelElement.call( this, $.extend( {
+ label: mw.msg( 'rcfilters-filterlist-title' ),
+ $label: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
+ }, config ) );
+
+ // "Back" to default view button
+ this.backButton = new OO.ui.ButtonWidget( {
+ icon: 'previous',
+ framed: false,
+ title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
+ } );
+ this.backButton.toggle( this.model.getCurrentView() !== 'default' );
+
+ // Help icon for Tagged edits
+ this.helpIcon = new OO.ui.ButtonWidget( {
+ icon: 'helpNotice',
+ framed: false,
+ title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
+ href: mw.util.getUrl( 'Special:Tags' ),
+ target: '_blank'
+ } );
+ this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
+
+ // Highlight button
+ this.highlightButton = new OO.ui.ToggleButtonWidget( {
+ icon: 'highlight',
+ label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
+ } );
+
+ // Invert namespaces button
+ this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
+ icon: '',
+ classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
+ } );
+ this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
+
+ // Events
+ this.backButton.connect( this, { click: 'onBackButtonClick' } );
+ this.highlightButton
+ .connect( this, { click: 'onHighlightButtonClick' } );
+ this.invertNamespacesButton
+ .connect( this, { click: 'onInvertNamespacesButtonClick' } );
+ this.model.connect( this, {
+ highlightChange: 'onModelHighlightChange',
+ searchChange: 'onModelSearchChange',
+ initialize: 'onModelInitialize'
+ } );
+ this.view = this.model.getCurrentView();
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
+ .append( this.backButton.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
+ .append( this.$label, this.helpIcon.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
+ .append( this.invertNamespacesButton.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
+ .append( this.highlightButton.$element )
+ )
+ )
);
- };
-
- FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
- this.controller.switchView( 'default' );
- };
-
- /**
- * Respond to highlight button click
- */
- FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
- this.controller.toggleHighlight();
- };
-
- /**
- * Respond to highlight button click
- */
- FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
- this.controller.toggleInvertedNamespaces();
- };
-
- module.exports = FilterMenuHeaderWidget;
-}() );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
+OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
+
+/* Methods */
+
+/**
+ * Respond to model initialization event
+ *
+ * Note: need to wait for initialization before getting the invertModel
+ * and registering its update event. Creating all the models before the UI
+ * would help with that.
+ */
+FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
+ this.invertModel = this.model.getInvertModel();
+ this.updateInvertButton();
+ this.invertModel.connect( this, { update: 'updateInvertButton' } );
+};
+
+/**
+ * Respond to model update event
+ */
+FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
+ var currentView = this.model.getCurrentView();
+
+ if ( this.view !== currentView ) {
+ this.setLabel( this.model.getViewTitle( currentView ) );
+
+ this.invertNamespacesButton.toggle( currentView === 'namespaces' );
+ this.backButton.toggle( currentView !== 'default' );
+ this.helpIcon.toggle( currentView === 'tags' );
+ this.view = currentView;
+ }
+};
+
+/**
+ * Respond to model highlight change event
+ *
+ * @param {boolean} highlightEnabled Highlight is enabled
+ */
+FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
+ this.highlightButton.setActive( highlightEnabled );
+};
+
+/**
+ * Update the state of the invert button
+ */
+FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
+ this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
+ this.invertNamespacesButton.setLabel(
+ this.invertModel.isSelected() ?
+ mw.msg( 'rcfilters-exclude-button-on' ) :
+ mw.msg( 'rcfilters-exclude-button-off' )
+ );
+};
+
+FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
+ this.controller.switchView( 'default' );
+};
+
+/**
+ * Respond to highlight button click
+ */
+FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
+ this.controller.toggleHighlight();
+};
+
+/**
+ * Respond to highlight button click
+ */
+FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
+ this.controller.toggleInvertedNamespaces();
+};
+
+module.exports = FilterMenuHeaderWidget;
-( function () {
- var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
- FilterMenuOptionWidget;
+var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
+ FilterMenuOptionWidget;
- /**
- * A widget representing a single toggle filter
- *
- * @class mw.rcfilters.ui.FilterMenuOptionWidget
- * @extends mw.rcfilters.ui.ItemMenuOptionWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.FilterItem} invertModel
- * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
- * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
- * @param {Object} config Configuration object
- */
- FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
- controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
- ) {
- config = config || {};
+/**
+ * A widget representing a single toggle filter
+ *
+ * @class mw.rcfilters.ui.FilterMenuOptionWidget
+ * @extends mw.rcfilters.ui.ItemMenuOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
+ * @param {Object} config Configuration object
+ */
+FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
+ controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+) {
+ config = config || {};
- this.controller = controller;
- this.invertModel = invertModel;
- this.model = itemModel;
+ this.controller = controller;
+ this.invertModel = invertModel;
+ this.model = itemModel;
- // Parent
- FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
+ // Parent
+ FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
- // Event
- this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
+ // Event
+ this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
- this.$element
- .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
- };
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
+};
- /* Initialization */
- OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
+/* Initialization */
+OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
- /* Static properties */
+/* Static properties */
- // We do our own scrolling to top
- FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+// We do our own scrolling to top
+FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
- /* Methods */
+/* Methods */
- /**
- * @inheritdoc
- */
- FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
- // Parent
- FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
+/**
+ * @inheritdoc
+ */
+FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+ // Parent
+ FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
- this.setCurrentMuteState();
- };
+ this.setCurrentMuteState();
+};
- /**
- * Respond to item group model update event
- */
- FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
- this.setCurrentMuteState();
- };
+/**
+ * Respond to item group model update event
+ */
+FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
+ this.setCurrentMuteState();
+};
- /**
- * Set the current muted view of the widget based on its state
- */
- FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
- if (
- this.model.getGroupModel().getView() === 'namespaces' &&
- this.invertModel.isSelected()
- ) {
- // This is an inverted behavior than the other rules, specifically
- // for inverted namespaces
- this.setFlags( {
- muted: this.model.isSelected()
- } );
- } else {
- this.setFlags( {
- muted: (
- this.model.isConflicted() ||
- (
- // Item is also muted when any of the items in its group is active
- this.model.getGroupModel().isActive() &&
- // But it isn't selected
- !this.model.isSelected() &&
- // And also not included
- !this.model.isIncluded()
- )
+/**
+ * Set the current muted view of the widget based on its state
+ */
+FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
+ if (
+ this.model.getGroupModel().getView() === 'namespaces' &&
+ this.invertModel.isSelected()
+ ) {
+ // This is an inverted behavior than the other rules, specifically
+ // for inverted namespaces
+ this.setFlags( {
+ muted: this.model.isSelected()
+ } );
+ } else {
+ this.setFlags( {
+ muted: (
+ this.model.isConflicted() ||
+ (
+ // Item is also muted when any of the items in its group is active
+ this.model.getGroupModel().isActive() &&
+ // But it isn't selected
+ !this.model.isSelected() &&
+ // And also not included
+ !this.model.isIncluded()
)
- } );
- }
- };
+ )
+ } );
+ }
+};
- module.exports = FilterMenuOptionWidget;
-}() );
+module.exports = FilterMenuOptionWidget;
-( function () {
- /**
- * A widget representing a menu section for filter groups
- *
- * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
- * @extends OO.ui.MenuSectionOptionWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
- * @param {Object} config Configuration object
- * @cfg {jQuery} [$overlay] Overlay
- */
- var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
- var whatsThisMessages,
- $header = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
- $popupContent = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.$overlay = config.$overlay || this.$element;
-
- // Parent
- FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
- label: this.model.getTitle(),
- $label: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
- }, config ) );
-
- $header.append( this.$label );
-
- if ( this.model.hasWhatsThis() ) {
- whatsThisMessages = this.model.getWhatsThis();
-
- // Create popup
- if ( whatsThisMessages.header ) {
- $popupContent.append(
- ( new OO.ui.LabelWidget( {
- label: mw.msg( whatsThisMessages.header ),
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
- } ) ).$element
- );
- }
- if ( whatsThisMessages.body ) {
- $popupContent.append(
- ( new OO.ui.LabelWidget( {
- label: mw.msg( whatsThisMessages.body ),
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
- } ) ).$element
- );
- }
- if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
- $popupContent.append(
- ( new OO.ui.ButtonWidget( {
- framed: false,
- flags: [ 'progressive' ],
- href: whatsThisMessages.url,
- label: mw.msg( whatsThisMessages.linkText ),
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
- } ) ).$element
- );
- }
-
- // Add button
- this.whatsThisButton = new OO.ui.PopupButtonWidget( {
- framed: false,
- label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
- $overlay: this.$overlay,
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
- flags: [ 'progressive' ],
- popup: {
- padded: false,
- align: 'center',
- position: 'above',
- $content: $popupContent,
- classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
- }
- } );
-
- $header
- .append( this.whatsThisButton.$element );
+/**
+ * A widget representing a menu section for filter groups
+ *
+ * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
+ * @extends OO.ui.MenuSectionOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] Overlay
+ */
+var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
+ var whatsThisMessages,
+ $header = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
+ $popupContent = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.$overlay = config.$overlay || this.$element;
+
+ // Parent
+ FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
+ label: this.model.getTitle(),
+ $label: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
+ }, config ) );
+
+ $header.append( this.$label );
+
+ if ( this.model.hasWhatsThis() ) {
+ whatsThisMessages = this.model.getWhatsThis();
+
+ // Create popup
+ if ( whatsThisMessages.header ) {
+ $popupContent.append(
+ ( new OO.ui.LabelWidget( {
+ label: mw.msg( whatsThisMessages.header ),
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
+ } ) ).$element
+ );
+ }
+ if ( whatsThisMessages.body ) {
+ $popupContent.append(
+ ( new OO.ui.LabelWidget( {
+ label: mw.msg( whatsThisMessages.body ),
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
+ } ) ).$element
+ );
+ }
+ if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
+ $popupContent.append(
+ ( new OO.ui.ButtonWidget( {
+ framed: false,
+ flags: [ 'progressive' ],
+ href: whatsThisMessages.url,
+ label: mw.msg( whatsThisMessages.linkText ),
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
+ } ) ).$element
+ );
}
- // Events
- this.model.connect( this, { update: 'updateUiBasedOnState' } );
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
- .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
- .append( $header );
- this.updateUiBasedOnState();
- };
-
- /* Initialize */
-
- OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
-
- /* Methods */
-
- /**
- * Respond to model update event
- */
- FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
- this.$element.toggleClass(
- 'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
- this.model.isActive()
- );
- this.toggle( this.model.isVisible() );
- };
-
- /**
- * Get the group name
- *
- * @return {string} Group name
- */
- FilterMenuSectionOptionWidget.prototype.getName = function () {
- return this.model.getName();
- };
-
- module.exports = FilterMenuSectionOptionWidget;
-
-}() );
+ // Add button
+ this.whatsThisButton = new OO.ui.PopupButtonWidget( {
+ framed: false,
+ label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
+ $overlay: this.$overlay,
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
+ flags: [ 'progressive' ],
+ popup: {
+ padded: false,
+ align: 'center',
+ position: 'above',
+ $content: $popupContent,
+ classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
+ }
+ } );
+
+ $header
+ .append( this.whatsThisButton.$element );
+ }
+
+ // Events
+ this.model.connect( this, { update: 'updateUiBasedOnState' } );
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
+ .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
+ .append( $header );
+ this.updateUiBasedOnState();
+};
+
+/* Initialize */
+
+OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
+ this.$element.toggleClass(
+ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
+ this.model.isActive()
+ );
+ this.toggle( this.model.isVisible() );
+};
+
+/**
+ * Get the group name
+ *
+ * @return {string} Group name
+ */
+FilterMenuSectionOptionWidget.prototype.getName = function () {
+ return this.model.getName();
+};
+
+module.exports = FilterMenuSectionOptionWidget;
-( function () {
- var TagItemWidget = require( './TagItemWidget.js' ),
- FilterTagItemWidget;
-
- /**
- * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
- *
- * @class mw.rcfilters.ui.FilterTagItemWidget
- * @extends mw.rcfilters.ui.TagItemWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.FilterItem} invertModel
- * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
- * @param {Object} config Configuration object
- */
- FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
- controller, filtersViewModel, invertModel, itemModel, config
- ) {
- config = config || {};
-
- FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
- };
-
- /* Initialization */
-
- OO.inheritClass( FilterTagItemWidget, TagItemWidget );
-
- /* Methods */
-
- /**
- * @inheritdoc
- */
- FilterTagItemWidget.prototype.setCurrentMuteState = function () {
- this.setFlags( {
- muted: (
- !this.itemModel.isSelected() ||
- this.itemModel.isIncluded() ||
- this.itemModel.isFullyCovered()
- ),
- invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
- } );
- };
-
- module.exports = FilterTagItemWidget;
-}() );
+var TagItemWidget = require( './TagItemWidget.js' ),
+ FilterTagItemWidget;
+
+/**
+ * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
+ *
+ * @class mw.rcfilters.ui.FilterTagItemWidget
+ * @extends mw.rcfilters.ui.TagItemWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+ * @param {Object} config Configuration object
+ */
+FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
+ controller, filtersViewModel, invertModel, itemModel, config
+) {
+ config = config || {};
+
+ FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterTagItemWidget, TagItemWidget );
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+FilterTagItemWidget.prototype.setCurrentMuteState = function () {
+ this.setFlags( {
+ muted: (
+ !this.itemModel.isSelected() ||
+ this.itemModel.isIncluded() ||
+ this.itemModel.isFullyCovered()
+ ),
+ invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
+ } );
+};
+
+module.exports = FilterTagItemWidget;
-( function () {
- var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
- SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
- MenuSelectWidget = require( './MenuSelectWidget.js' ),
- FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
- FilterTagMultiselectWidget;
-
- /**
- * List displaying all filter groups
- *
- * @class mw.rcfilters.ui.FilterTagMultiselectWidget
- * @extends OO.ui.MenuTagMultiselectWidget
- * @mixins OO.ui.mixin.PendingElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
- * @param {Object} config Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
- * system. If not given, falls back to this widget's $element
- * @cfg {boolean} [collapsed] Filter area is collapsed
- */
- FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
- var rcFiltersRow,
- title = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-activefilters' ),
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
- } ),
- $contentWrapper = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.queriesModel = savedQueriesModel;
- this.$overlay = config.$overlay || this.$element;
- this.$wrapper = config.$wrapper || this.$element;
- this.matchingQuery = null;
- this.currentView = this.model.getCurrentView();
- this.collapsed = false;
-
- // Parent
- FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
- label: mw.msg( 'rcfilters-filterlist-title' ),
- placeholder: mw.msg( 'rcfilters-empty-filter' ),
- inputPosition: 'outline',
- allowArbitrary: false,
- allowDisplayInvalidTags: false,
- allowReordering: false,
- $overlay: this.$overlay,
- menu: {
- // Our filtering is done through the model
- filterFromInput: false,
- hideWhenOutOfView: false,
- hideOnChoose: false,
- width: 650,
- footers: [
- {
- name: 'viewSelect',
- sticky: false,
- // View select menu, appears on default view only
- $element: $( '<div>' )
- .append( new ViewSwitchWidget( this.controller, this.model ).$element ),
- views: [ 'default' ]
- },
- {
- name: 'feedback',
- // Feedback footer, appears on all views
- $element: $( '<div>' )
- .append(
- new OO.ui.ButtonWidget( {
- framed: false,
- icon: 'feedback',
- flags: [ 'progressive' ],
- label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
- href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
- } ).$element
- )
- }
- ]
- },
- input: {
- icon: 'menu',
- placeholder: mw.msg( 'rcfilters-search-placeholder' )
- }
- }, config ) );
-
- this.savedQueryTitle = new OO.ui.LabelWidget( {
- label: '',
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
- } );
-
- this.resetButton = new OO.ui.ButtonWidget( {
- framed: false,
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
- } );
-
- this.hideShowButton = new OO.ui.ButtonWidget( {
- framed: false,
- flags: [ 'progressive' ],
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
- } );
- this.toggleCollapsed( !!config.collapsed );
-
- if ( !mw.user.isAnon() ) {
- this.saveQueryButton = new SaveFiltersPopupButtonWidget(
- this.controller,
- this.queriesModel,
+var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
+ SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
+ MenuSelectWidget = require( './MenuSelectWidget.js' ),
+ FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
+ FilterTagMultiselectWidget;
+
+/**
+ * List displaying all filter groups
+ *
+ * @class mw.rcfilters.ui.FilterTagMultiselectWidget
+ * @extends OO.ui.MenuTagMultiselectWidget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ * system. If not given, falls back to this widget's $element
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ */
+FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
+ var rcFiltersRow,
+ title = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-activefilters' ),
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
+ } ),
+ $contentWrapper = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.queriesModel = savedQueriesModel;
+ this.$overlay = config.$overlay || this.$element;
+ this.$wrapper = config.$wrapper || this.$element;
+ this.matchingQuery = null;
+ this.currentView = this.model.getCurrentView();
+ this.collapsed = false;
+
+ // Parent
+ FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
+ label: mw.msg( 'rcfilters-filterlist-title' ),
+ placeholder: mw.msg( 'rcfilters-empty-filter' ),
+ inputPosition: 'outline',
+ allowArbitrary: false,
+ allowDisplayInvalidTags: false,
+ allowReordering: false,
+ $overlay: this.$overlay,
+ menu: {
+ // Our filtering is done through the model
+ filterFromInput: false,
+ hideWhenOutOfView: false,
+ hideOnChoose: false,
+ width: 650,
+ footers: [
+ {
+ name: 'viewSelect',
+ sticky: false,
+ // View select menu, appears on default view only
+ $element: $( '<div>' )
+ .append( new ViewSwitchWidget( this.controller, this.model ).$element ),
+ views: [ 'default' ]
+ },
{
- $overlay: this.$overlay
+ name: 'feedback',
+ // Feedback footer, appears on all views
+ $element: $( '<div>' )
+ .append(
+ new OO.ui.ButtonWidget( {
+ framed: false,
+ icon: 'feedback',
+ flags: [ 'progressive' ],
+ label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
+ href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
+ } ).$element
+ )
}
- );
-
- this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
- e.stopPropagation();
- } );
-
- this.saveQueryButton.connect( this, {
- click: 'onSaveQueryButtonClick',
- saveCurrent: 'setSavedQueryVisibility'
- } );
- this.queriesModel.connect( this, {
- itemUpdate: 'onSavedQueriesItemUpdate',
- initialize: 'onSavedQueriesInitialize',
- default: 'reevaluateResetRestoreState'
- } );
+ ]
+ },
+ input: {
+ icon: 'menu',
+ placeholder: mw.msg( 'rcfilters-search-placeholder' )
}
+ }, config ) );
+
+ this.savedQueryTitle = new OO.ui.LabelWidget( {
+ label: '',
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
+ } );
+
+ this.resetButton = new OO.ui.ButtonWidget( {
+ framed: false,
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
+ } );
+
+ this.hideShowButton = new OO.ui.ButtonWidget( {
+ framed: false,
+ flags: [ 'progressive' ],
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
+ } );
+ this.toggleCollapsed( !!config.collapsed );
+
+ if ( !mw.user.isAnon() ) {
+ this.saveQueryButton = new SaveFiltersPopupButtonWidget(
+ this.controller,
+ this.queriesModel,
+ {
+ $overlay: this.$overlay
+ }
+ );
- this.emptyFilterMessage = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-empty-filter' ),
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
- } );
- this.$content.append( this.emptyFilterMessage.$element );
-
- // Events
- this.resetButton.connect( this, { click: 'onResetButtonClick' } );
- this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
- // Stop propagation for mousedown, so that the widget doesn't
- // trigger the focus on the input and scrolls up when we click the reset button
- this.resetButton.$element.on( 'mousedown', function ( e ) {
- e.stopPropagation();
- } );
- this.hideShowButton.$element.on( 'mousedown', function ( e ) {
+ this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
e.stopPropagation();
} );
- this.model.connect( this, {
- initialize: 'onModelInitialize',
- update: 'onModelUpdate',
- searchChange: 'onModelSearchChange',
- itemUpdate: 'onModelItemUpdate',
- highlightChange: 'onModelHighlightChange'
- } );
- this.input.connect( this, { change: 'onInputChange' } );
-
- // The filter list and button should appear side by side regardless of how
- // wide the button is; the button also changes its width depending
- // on language and its state, so the safest way to present both side
- // by side is with a table layout
- rcFiltersRow = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- this.$content
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
- );
- if ( !mw.user.isAnon() ) {
- rcFiltersRow.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
- .append( this.saveQueryButton.$element )
- );
- }
-
- // Add a selector at the right of the input
- this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
- classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
- items: [
- new OO.ui.ButtonOptionWidget( {
- framed: false,
- data: 'namespaces',
- icon: 'article',
- label: mw.msg( 'namespaces' ),
- title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
- } ),
- new OO.ui.ButtonOptionWidget( {
- framed: false,
- data: 'tags',
- icon: 'tag',
- label: mw.msg( 'tags-title' ),
- title: mw.msg( 'rcfilters-view-tags-tooltip' )
- } )
- ]
+ this.saveQueryButton.connect( this, {
+ click: 'onSaveQueryButtonClick',
+ saveCurrent: 'setSavedQueryVisibility'
} );
-
- // Rearrange the UI so the select widget is at the right of the input
- this.$element.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
- .append( this.input.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
- .append( this.viewsSelectWidget.$element )
- )
- )
+ this.queriesModel.connect( this, {
+ itemUpdate: 'onSavedQueriesItemUpdate',
+ initialize: 'onSavedQueriesInitialize',
+ default: 'reevaluateResetRestoreState'
+ } );
+ }
+
+ this.emptyFilterMessage = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-empty-filter' ),
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
+ } );
+ this.$content.append( this.emptyFilterMessage.$element );
+
+ // Events
+ this.resetButton.connect( this, { click: 'onResetButtonClick' } );
+ this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
+ // Stop propagation for mousedown, so that the widget doesn't
+ // trigger the focus on the input and scrolls up when we click the reset button
+ this.resetButton.$element.on( 'mousedown', function ( e ) {
+ e.stopPropagation();
+ } );
+ this.hideShowButton.$element.on( 'mousedown', function ( e ) {
+ e.stopPropagation();
+ } );
+ this.model.connect( this, {
+ initialize: 'onModelInitialize',
+ update: 'onModelUpdate',
+ searchChange: 'onModelSearchChange',
+ itemUpdate: 'onModelItemUpdate',
+ highlightChange: 'onModelHighlightChange'
+ } );
+ this.input.connect( this, { change: 'onInputChange' } );
+
+ // The filter list and button should appear side by side regardless of how
+ // wide the button is; the button also changes its width depending
+ // on language and its state, so the safest way to present both side
+ // by side is with a table layout
+ rcFiltersRow = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ this.$content
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
);
- // Event
- this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
-
+ if ( !mw.user.isAnon() ) {
rcFiltersRow.append(
$( '<div>' )
.addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
- .append( this.resetButton.$element )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
+ .append( this.saveQueryButton.$element )
);
-
- // Build the content
- $contentWrapper.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
- .append( title.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
- .append( this.savedQueryTitle.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
- .append(
- this.hideShowButton.$element
- )
- ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
- .append( rcFiltersRow )
- );
-
- // Initialize
- this.$handle.append( $contentWrapper );
- this.emptyFilterMessage.toggle( this.isEmpty() );
- this.savedQueryTitle.toggle( false );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
-
- this.reevaluateResetRestoreState();
- };
-
- /* Initialization */
-
- OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
-
- /* Methods */
-
- /**
- * Override parent method to avoid unnecessary resize events.
- */
- FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
-
- /**
- * Respond to view select widget choose event
- *
- * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
- */
- FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
- this.controller.switchView( buttonOptionWidget.getData() );
- this.viewsSelectWidget.selectItem( null );
+ }
+
+ // Add a selector at the right of the input
+ this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
+ classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
+ items: [
+ new OO.ui.ButtonOptionWidget( {
+ framed: false,
+ data: 'namespaces',
+ icon: 'article',
+ label: mw.msg( 'namespaces' ),
+ title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
+ } ),
+ new OO.ui.ButtonOptionWidget( {
+ framed: false,
+ data: 'tags',
+ icon: 'tag',
+ label: mw.msg( 'tags-title' ),
+ title: mw.msg( 'rcfilters-view-tags-tooltip' )
+ } )
+ ]
+ } );
+
+ // Rearrange the UI so the select widget is at the right of the input
+ this.$element.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
+ .append( this.input.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
+ .append( this.viewsSelectWidget.$element )
+ )
+ )
+ );
+
+ // Event
+ this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
+
+ rcFiltersRow.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
+ .append( this.resetButton.$element )
+ );
+
+ // Build the content
+ $contentWrapper.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
+ .append( title.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
+ .append( this.savedQueryTitle.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
+ .append(
+ this.hideShowButton.$element
+ )
+ ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
+ .append( rcFiltersRow )
+ );
+
+ // Initialize
+ this.$handle.append( $contentWrapper );
+ this.emptyFilterMessage.toggle( this.isEmpty() );
+ this.savedQueryTitle.toggle( false );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
+
+ this.reevaluateResetRestoreState();
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
+
+/* Methods */
+
+/**
+ * Override parent method to avoid unnecessary resize events.
+ */
+FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
+
+/**
+ * Respond to view select widget choose event
+ *
+ * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
+ */
+FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
+ this.controller.switchView( buttonOptionWidget.getData() );
+ this.viewsSelectWidget.selectItem( null );
+ this.focus();
+};
+
+/**
+ * Respond to model search change event
+ *
+ * @param {string} value Search value
+ */
+FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
+ this.input.setValue( value );
+};
+
+/**
+ * Respond to input change event
+ *
+ * @param {string} value Value of the input
+ */
+FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
+ this.controller.setSearch( value );
+};
+
+/**
+ * Respond to query button click
+ */
+FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
+ this.getMenu().toggle( false );
+};
+
+/**
+ * Respond to save query model initialization
+ */
+FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
+ this.setSavedQueryVisibility();
+};
+
+/**
+ * Respond to save query item change. Mainly this is done to update the label in case
+ * a query item has been edited
+ *
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
+ */
+FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
+ if ( this.matchingQuery === item ) {
+ // This means we just edited the item that is currently matched
+ this.savedQueryTitle.setLabel( item.getLabel() );
+ }
+};
+
+/**
+ * Respond to menu toggle
+ *
+ * @param {boolean} isVisible Menu is visible
+ */
+FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
+ // Parent
+ FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
+
+ if ( isVisible ) {
this.focus();
- };
-
- /**
- * Respond to model search change event
- *
- * @param {string} value Search value
- */
- FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
- this.input.setValue( value );
- };
-
- /**
- * Respond to input change event
- *
- * @param {string} value Value of the input
- */
- FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
- this.controller.setSearch( value );
- };
-
- /**
- * Respond to query button click
- */
- FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
- this.getMenu().toggle( false );
- };
-
- /**
- * Respond to save query model initialization
- */
- FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
- this.setSavedQueryVisibility();
- };
-
- /**
- * Respond to save query item change. Mainly this is done to update the label in case
- * a query item has been edited
- *
- * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
- */
- FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
- if ( this.matchingQuery === item ) {
- // This means we just edited the item that is currently matched
- this.savedQueryTitle.setLabel( item.getLabel() );
- }
- };
-
- /**
- * Respond to menu toggle
- *
- * @param {boolean} isVisible Menu is visible
- */
- FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
- // Parent
- FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
-
- if ( isVisible ) {
- this.focus();
-
- mw.hook( 'RcFilters.popup.open' ).fire();
-
- if ( !this.getMenu().findSelectedItem() ) {
- // If there are no selected items, scroll menu to top
- // This has to be in a setTimeout so the menu has time
- // to be positioned and fixed
- setTimeout(
- function () {
- this.getMenu().scrollToTop();
- }.bind( this )
- );
- }
- } else {
- // Clear selection
- this.selectTag( null );
- // Clear the search
- this.controller.setSearch( '' );
+ mw.hook( 'RcFilters.popup.open' ).fire();
- // Log filter grouping
- this.controller.trackFilterGroupings( 'filtermenu' );
-
- this.blur();
- }
-
- this.input.setIcon( isVisible ? 'search' : 'menu' );
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.onInputFocus = function () {
- // Parent
- FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
-
- // Only scroll to top of the viewport if:
- // - The widget is more than 20px from the top
- // - The widget is not above the top of the viewport (do not scroll downwards)
- // (This isn't represented because >20 is, anyways and always, bigger than 0)
- this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.doInputEscape = function () {
- // Parent
- FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
-
- // Blur the input
- this.input.$input.trigger( 'blur' );
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
- if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
- this.menu.toggle();
-
- return false;
- }
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.onChangeTags = function () {
- // If initialized, call parent method.
- if ( this.controller.isInitialized() ) {
- FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
- }
-
- this.emptyFilterMessage.toggle( this.isEmpty() );
- };
-
- /**
- * Respond to model initialize event
- */
- FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
- this.setSavedQueryVisibility();
- };
-
- /**
- * Respond to model update event
- */
- FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
- this.updateElementsForView();
- };
-
- /**
- * Update the elements in the widget to the current view
- */
- FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
- var view = this.model.getCurrentView(),
- inputValue = this.input.getValue().trim(),
- inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
-
- if ( inputView !== 'default' ) {
- // We have a prefix already, remove it
- inputValue = inputValue.substr( 1 );
- }
-
- if ( inputView !== view ) {
- // Add the correct prefix
- inputValue = this.model.getViewTrigger( view ) + inputValue;
- }
-
- // Update input
- this.input.setValue( inputValue );
-
- if ( this.currentView !== view ) {
- this.scrollToTop( this.$element );
- this.currentView = view;
- }
- };
-
- /**
- * Set the visibility of the saved query button
- */
- FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
- if ( mw.user.isAnon() ) {
- return;
- }
-
- this.matchingQuery = this.controller.findQueryMatchingCurrentState();
-
- this.savedQueryTitle.setLabel(
- this.matchingQuery ? this.matchingQuery.getLabel() : ''
- );
- this.savedQueryTitle.toggle( !!this.matchingQuery );
- this.saveQueryButton.setDisabled( !!this.matchingQuery );
- this.saveQueryButton.setTitle( !this.matchingQuery ?
- mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
- mw.msg( 'rcfilters-savedqueries-already-saved' ) );
-
- if ( this.matchingQuery ) {
- this.emphasize();
+ if ( !this.getMenu().findSelectedItem() ) {
+ // If there are no selected items, scroll menu to top
+ // This has to be in a setTimeout so the menu has time
+ // to be positioned and fixed
+ setTimeout(
+ function () {
+ this.getMenu().scrollToTop();
+ }.bind( this )
+ );
}
- };
-
- /**
- * Respond to model itemUpdate event
- * fixme: when a new state is applied to the model this function is called 60+ times in a row
- *
- * @param {mw.rcfilters.dm.FilterItem} item Filter item model
- */
- FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
- if ( !item.getGroupModel().isHidden() ) {
- if (
- item.isSelected() ||
- (
- this.model.isHighlightEnabled() &&
- item.getHighlightColor()
- )
- ) {
- this.addTag( item.getName(), item.getLabel() );
- } else {
- // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
- if ( this.findItemFromData( item.getName() ) !== null ) {
- this.removeTagByData( item.getName() );
- }
+ } else {
+ // Clear selection
+ this.selectTag( null );
+
+ // Clear the search
+ this.controller.setSearch( '' );
+
+ // Log filter grouping
+ this.controller.trackFilterGroupings( 'filtermenu' );
+
+ this.blur();
+ }
+
+ this.input.setIcon( isVisible ? 'search' : 'menu' );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onInputFocus = function () {
+ // Parent
+ FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
+
+ // Only scroll to top of the viewport if:
+ // - The widget is more than 20px from the top
+ // - The widget is not above the top of the viewport (do not scroll downwards)
+ // (This isn't represented because >20 is, anyways and always, bigger than 0)
+ this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.doInputEscape = function () {
+ // Parent
+ FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
+
+ // Blur the input
+ this.input.$input.trigger( 'blur' );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
+ if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+ this.menu.toggle();
+
+ return false;
+ }
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onChangeTags = function () {
+ // If initialized, call parent method.
+ if ( this.controller.isInitialized() ) {
+ FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
+ }
+
+ this.emptyFilterMessage.toggle( this.isEmpty() );
+};
+
+/**
+ * Respond to model initialize event
+ */
+FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
+ this.setSavedQueryVisibility();
+};
+
+/**
+ * Respond to model update event
+ */
+FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
+ this.updateElementsForView();
+};
+
+/**
+ * Update the elements in the widget to the current view
+ */
+FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
+ var view = this.model.getCurrentView(),
+ inputValue = this.input.getValue().trim(),
+ inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
+
+ if ( inputView !== 'default' ) {
+ // We have a prefix already, remove it
+ inputValue = inputValue.substr( 1 );
+ }
+
+ if ( inputView !== view ) {
+ // Add the correct prefix
+ inputValue = this.model.getViewTrigger( view ) + inputValue;
+ }
+
+ // Update input
+ this.input.setValue( inputValue );
+
+ if ( this.currentView !== view ) {
+ this.scrollToTop( this.$element );
+ this.currentView = view;
+ }
+};
+
+/**
+ * Set the visibility of the saved query button
+ */
+FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
+ if ( mw.user.isAnon() ) {
+ return;
+ }
+
+ this.matchingQuery = this.controller.findQueryMatchingCurrentState();
+
+ this.savedQueryTitle.setLabel(
+ this.matchingQuery ? this.matchingQuery.getLabel() : ''
+ );
+ this.savedQueryTitle.toggle( !!this.matchingQuery );
+ this.saveQueryButton.setDisabled( !!this.matchingQuery );
+ this.saveQueryButton.setTitle( !this.matchingQuery ?
+ mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
+ mw.msg( 'rcfilters-savedqueries-already-saved' ) );
+
+ if ( this.matchingQuery ) {
+ this.emphasize();
+ }
+};
+
+/**
+ * Respond to model itemUpdate event
+ * fixme: when a new state is applied to the model this function is called 60+ times in a row
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item model
+ */
+FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
+ if ( !item.getGroupModel().isHidden() ) {
+ if (
+ item.isSelected() ||
+ (
+ this.model.isHighlightEnabled() &&
+ item.getHighlightColor()
+ )
+ ) {
+ this.addTag( item.getName(), item.getLabel() );
+ } else {
+ // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+ if ( this.findItemFromData( item.getName() ) !== null ) {
+ this.removeTagByData( item.getName() );
}
}
-
- this.setSavedQueryVisibility();
-
- // Re-evaluate reset state
- this.reevaluateResetRestoreState();
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
- return (
- this.model.getItemByName( data ) &&
- !this.isDuplicateData( data )
- );
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
- this.controller.toggleFilterSelect( item.model.getName() );
-
- // Select the tag if it exists, or reset selection otherwise
- this.selectTag( this.findItemFromData( item.model.getName() ) );
-
- this.focus();
- };
-
- /**
- * Respond to highlightChange event
- *
- * @param {boolean} isHighlightEnabled Highlight is enabled
- */
- FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
- var highlightedItems = this.model.getHighlightedItems();
-
- if ( isHighlightEnabled ) {
- // Add capsule widgets
- highlightedItems.forEach( function ( filterItem ) {
- this.addTag( filterItem.getName(), filterItem.getLabel() );
- }.bind( this ) );
- } else {
- // Remove capsule widgets if they're not selected
- highlightedItems.forEach( function ( filterItem ) {
- if ( !filterItem.isSelected() ) {
- // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
- if ( this.findItemFromData( filterItem.getName() ) !== null ) {
- this.removeTagByData( filterItem.getName() );
- }
+ }
+
+ this.setSavedQueryVisibility();
+
+ // Re-evaluate reset state
+ this.reevaluateResetRestoreState();
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
+ return (
+ this.model.getItemByName( data ) &&
+ !this.isDuplicateData( data )
+ );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
+ this.controller.toggleFilterSelect( item.model.getName() );
+
+ // Select the tag if it exists, or reset selection otherwise
+ this.selectTag( this.findItemFromData( item.model.getName() ) );
+
+ this.focus();
+};
+
+/**
+ * Respond to highlightChange event
+ *
+ * @param {boolean} isHighlightEnabled Highlight is enabled
+ */
+FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
+ var highlightedItems = this.model.getHighlightedItems();
+
+ if ( isHighlightEnabled ) {
+ // Add capsule widgets
+ highlightedItems.forEach( function ( filterItem ) {
+ this.addTag( filterItem.getName(), filterItem.getLabel() );
+ }.bind( this ) );
+ } else {
+ // Remove capsule widgets if they're not selected
+ highlightedItems.forEach( function ( filterItem ) {
+ if ( !filterItem.isSelected() ) {
+ // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+ if ( this.findItemFromData( filterItem.getName() ) !== null ) {
+ this.removeTagByData( filterItem.getName() );
}
- }.bind( this ) );
- }
-
- this.setSavedQueryVisibility();
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
- var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
-
- this.menu.setUserSelecting( true );
- // Parent method
- FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
-
- // Switch view
- this.controller.resetSearchForView( tagItem.getView() );
-
- this.selectTag( tagItem );
- this.scrollToTop( menuOption.$element );
-
- this.menu.setUserSelecting( false );
- };
-
- /**
- * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
- * If no items are given, reset selection from all.
- *
- * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
- * omit to deselect all
- */
- FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
- var i, len, selected;
-
- for ( i = 0, len = this.items.length; i < len; i++ ) {
- selected = this.items[ i ] === item;
- if ( this.items[ i ].isSelected() !== selected ) {
- this.items[ i ].toggleSelected( selected );
}
+ }.bind( this ) );
+ }
+
+ this.setSavedQueryVisibility();
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
+ var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
+
+ this.menu.setUserSelecting( true );
+ // Parent method
+ FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
+
+ // Switch view
+ this.controller.resetSearchForView( tagItem.getView() );
+
+ this.selectTag( tagItem );
+ this.scrollToTop( menuOption.$element );
+
+ this.menu.setUserSelecting( false );
+};
+
+/**
+ * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
+ * If no items are given, reset selection from all.
+ *
+ * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
+ * omit to deselect all
+ */
+FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
+ var i, len, selected;
+
+ for ( i = 0, len = this.items.length; i < len; i++ ) {
+ selected = this.items[ i ] === item;
+ if ( this.items[ i ].isSelected() !== selected ) {
+ this.items[ i ].toggleSelected( selected );
}
- };
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
- // Parent method
- FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
-
- this.controller.clearFilter( tagItem.getName() );
-
- tagItem.destroy();
- };
-
- /**
- * Respond to click event on the reset button
- */
- FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
- if ( this.model.areVisibleFiltersEmpty() ) {
- // Reset to default filters
- this.controller.resetToDefaults();
- } else {
- // Reset to have no filters
- this.controller.emptyFilters();
- }
- };
-
- /**
- * Respond to hide/show button click
- */
- FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
- this.toggleCollapsed();
- };
-
- /**
- * Toggle the collapsed state of the filters widget
- *
- * @param {boolean} isCollapsed Widget is collapsed
- */
- FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
- isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
-
- this.collapsed = isCollapsed;
-
- if ( isCollapsed ) {
- // If we are collapsing, close the menu, in case it was open
- // We should make sure the menu closes before the rest of the elements
- // are hidden, otherwise there is an unknown error in jQuery as ooui
- // sets and unsets properties on the input (which is hidden at that point)
- this.menu.toggle( false );
- }
- this.input.setDisabled( isCollapsed );
- this.hideShowButton.setLabel( mw.msg(
- isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
- ) );
- this.hideShowButton.setTitle( mw.msg(
- isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
- ) );
-
- // Toggle the wrapper class, so we have min height values correctly throughout
- this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
-
- // Save the state
- this.controller.updateCollapsedState( isCollapsed );
- };
-
- /**
- * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
- */
- FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
- var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
- currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
- hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
-
- this.resetButton.setIcon(
- currFiltersAreEmpty ? 'history' : 'trash'
- );
-
- this.resetButton.setLabel(
- currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
- );
- this.resetButton.setTitle(
- currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
- );
-
- this.resetButton.toggle( !hideResetButton );
- this.emptyFilterMessage.toggle( currFiltersAreEmpty );
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
- return new MenuSelectWidget(
+ }
+};
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
+ // Parent method
+ FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
+
+ this.controller.clearFilter( tagItem.getName() );
+
+ tagItem.destroy();
+};
+
+/**
+ * Respond to click event on the reset button
+ */
+FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
+ if ( this.model.areVisibleFiltersEmpty() ) {
+ // Reset to default filters
+ this.controller.resetToDefaults();
+ } else {
+ // Reset to have no filters
+ this.controller.emptyFilters();
+ }
+};
+
+/**
+ * Respond to hide/show button click
+ */
+FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
+ this.toggleCollapsed();
+};
+
+/**
+ * Toggle the collapsed state of the filters widget
+ *
+ * @param {boolean} isCollapsed Widget is collapsed
+ */
+FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
+ isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
+
+ this.collapsed = isCollapsed;
+
+ if ( isCollapsed ) {
+ // If we are collapsing, close the menu, in case it was open
+ // We should make sure the menu closes before the rest of the elements
+ // are hidden, otherwise there is an unknown error in jQuery as ooui
+ // sets and unsets properties on the input (which is hidden at that point)
+ this.menu.toggle( false );
+ }
+ this.input.setDisabled( isCollapsed );
+ this.hideShowButton.setLabel( mw.msg(
+ isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
+ ) );
+ this.hideShowButton.setTitle( mw.msg(
+ isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
+ ) );
+
+ // Toggle the wrapper class, so we have min height values correctly throughout
+ this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
+
+ // Save the state
+ this.controller.updateCollapsedState( isCollapsed );
+};
+
+/**
+ * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
+ */
+FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
+ var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
+ currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
+ hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
+
+ this.resetButton.setIcon(
+ currFiltersAreEmpty ? 'history' : 'trash'
+ );
+
+ this.resetButton.setLabel(
+ currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
+ );
+ this.resetButton.setTitle(
+ currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
+ );
+
+ this.resetButton.toggle( !hideResetButton );
+ this.emptyFilterMessage.toggle( currFiltersAreEmpty );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
+ return new MenuSelectWidget(
+ this.controller,
+ this.model,
+ menuConfig
+ );
+};
+
+/**
+ * @inheritdoc
+ */
+FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
+ var filterItem = this.model.getItemByName( data );
+
+ if ( filterItem ) {
+ return new FilterTagItemWidget(
this.controller,
this.model,
- menuConfig
+ this.model.getInvertModel(),
+ filterItem,
+ {
+ $overlay: this.$overlay
+ }
);
- };
-
- /**
- * @inheritdoc
- */
- FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
- var filterItem = this.model.getItemByName( data );
-
- if ( filterItem ) {
- return new FilterTagItemWidget(
- this.controller,
- this.model,
- this.model.getInvertModel(),
- filterItem,
- {
- $overlay: this.$overlay
- }
- );
- }
- };
-
- FilterTagMultiselectWidget.prototype.emphasize = function () {
- if (
- !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
- ) {
+ }
+};
+
+FilterTagMultiselectWidget.prototype.emphasize = function () {
+ if (
+ !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
+ ) {
+ this.$handle
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
+ .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+
+ setTimeout( function () {
this.$handle
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
- .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+ .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
setTimeout( function () {
this.$handle
- .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
-
- setTimeout( function () {
- this.$handle
- .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
- }.bind( this ), 1000 );
- }.bind( this ), 500 );
-
- }
- };
- /**
- * Scroll the element to top within its container
- *
- * @private
- * @param {jQuery} $element Element to position
- * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
- * much space (in pixels) above the widget.
- * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
- * @param {number} [threshold.min] Minimum distance above the element
- * @param {number} [threshold.max] Minimum distance below the element
- */
- FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
- var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
- pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
- containerScrollTop = $( container ).scrollTop(),
- effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
- newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
-
- // Scroll to item
- if (
- threshold === undefined ||
+ .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+ }.bind( this ), 1000 );
+ }.bind( this ), 500 );
+
+ }
+};
+/**
+ * Scroll the element to top within its container
+ *
+ * @private
+ * @param {jQuery} $element Element to position
+ * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
+ * much space (in pixels) above the widget.
+ * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
+ * @param {number} [threshold.min] Minimum distance above the element
+ * @param {number} [threshold.max] Minimum distance below the element
+ */
+FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
+ var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
+ pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
+ containerScrollTop = $( container ).scrollTop(),
+ effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
+ newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
+
+ // Scroll to item
+ if (
+ threshold === undefined ||
+ (
+ (
+ threshold.min === undefined ||
+ newScrollTop - containerScrollTop >= threshold.min
+ ) &&
(
- (
- threshold.min === undefined ||
- newScrollTop - containerScrollTop >= threshold.min
- ) &&
- (
- threshold.max === undefined ||
- newScrollTop - containerScrollTop <= threshold.max
- )
+ threshold.max === undefined ||
+ newScrollTop - containerScrollTop <= threshold.max
)
- ) {
- $( container ).animate( {
- scrollTop: newScrollTop
- } );
- }
- };
+ )
+ ) {
+ $( container ).animate( {
+ scrollTop: newScrollTop
+ } );
+ }
+};
- module.exports = FilterTagMultiselectWidget;
-}() );
+module.exports = FilterTagMultiselectWidget;
-( function () {
- var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
- LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
- ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
- FilterWrapperWidget;
-
- /**
- * List displaying all filter groups
- *
- * @class mw.rcfilters.ui.FilterWrapperWidget
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.PendingElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {Object} [config] Configuration object
- * @cfg {Object} [filters] A definition of the filter groups in this list
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
- * system. If not given, falls back to this widget's $element
- * @cfg {boolean} [collapsed] Filter area is collapsed
- */
- FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
- controller, model, savedQueriesModel, changesListModel, config
- ) {
- var $bottom;
- config = config || {};
-
- // Parent
- FilterWrapperWidget.parent.call( this, config );
- // Mixin constructors
- OO.ui.mixin.PendingElement.call( this, config );
-
- this.controller = controller;
- this.model = model;
- this.queriesModel = savedQueriesModel;
- this.changesListModel = changesListModel;
- this.$overlay = config.$overlay || this.$element;
- this.$wrapper = config.$wrapper || this.$element;
-
- this.filterTagWidget = new FilterTagMultiselectWidget(
- this.controller,
- this.model,
- this.queriesModel,
- {
- $overlay: this.$overlay,
- collapsed: config.collapsed,
- $wrapper: this.$wrapper
- }
+var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
+ LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
+ ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
+ FilterWrapperWidget;
+
+/**
+ * List displaying all filter groups
+ *
+ * @class mw.rcfilters.ui.FilterWrapperWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} [config] Configuration object
+ * @cfg {Object} [filters] A definition of the filter groups in this list
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ * system. If not given, falls back to this widget's $element
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ */
+FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
+ controller, model, savedQueriesModel, changesListModel, config
+) {
+ var $bottom;
+ config = config || {};
+
+ // Parent
+ FilterWrapperWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.PendingElement.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+ this.queriesModel = savedQueriesModel;
+ this.changesListModel = changesListModel;
+ this.$overlay = config.$overlay || this.$element;
+ this.$wrapper = config.$wrapper || this.$element;
+
+ this.filterTagWidget = new FilterTagMultiselectWidget(
+ this.controller,
+ this.model,
+ this.queriesModel,
+ {
+ $overlay: this.$overlay,
+ collapsed: config.collapsed,
+ $wrapper: this.$wrapper
+ }
+ );
+
+ this.liveUpdateButton = new LiveUpdateButtonWidget(
+ this.controller,
+ this.changesListModel
+ );
+
+ this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
+ this.controller,
+ this.model,
+ {
+ $overlay: this.$overlay
+ }
+ );
+
+ this.showNewChangesLink = new OO.ui.ButtonWidget( {
+ icon: 'reload',
+ framed: false,
+ label: mw.msg( 'rcfilters-show-new-changes' ),
+ flags: [ 'progressive' ],
+ classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
+ } );
+
+ // Events
+ this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
+ this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
+ this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
+ this.showNewChangesLink.toggle( false );
+
+ // Initialize
+ this.$top = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
+
+ $bottom = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
+ .append(
+ this.showNewChangesLink.$element,
+ this.numChangesAndDateWidget.$element
);
- this.liveUpdateButton = new LiveUpdateButtonWidget(
- this.controller,
- this.changesListModel
- );
+ if ( this.controller.pollingRate ) {
+ $bottom.prepend( this.liveUpdateButton.$element );
+ }
- this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
- this.controller,
- this.model,
- {
- $overlay: this.$overlay
- }
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
+ .append(
+ this.$top,
+ this.filterTagWidget.$element,
+ $bottom
);
-
- this.showNewChangesLink = new OO.ui.ButtonWidget( {
- icon: 'reload',
- framed: false,
- label: mw.msg( 'rcfilters-show-new-changes' ),
- flags: [ 'progressive' ],
- classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
- } );
-
- // Events
- this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
- this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
- this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
- this.showNewChangesLink.toggle( false );
-
- // Initialize
- this.$top = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
-
- $bottom = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
- .append(
- this.showNewChangesLink.$element,
- this.numChangesAndDateWidget.$element
- );
-
- if ( this.controller.pollingRate ) {
- $bottom.prepend( this.liveUpdateButton.$element );
- }
-
- this.$element
- .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
- .append(
- this.$top,
- this.filterTagWidget.$element,
- $bottom
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
- OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );
-
- /* Methods */
-
- /**
- * Set the content of the top section
- *
- * @param {jQuery} $topSectionElement
- */
- FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
- this.$top.append( $topSectionElement );
- };
-
- /**
- * Respond to the user clicking the 'show new changes' button
- */
- FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
- this.controller.showNewChanges();
- };
-
- /**
- * Respond to changes list model newChangesExist
- *
- * @param {boolean} newChangesExist Whether new changes exist
- */
- FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
- this.showNewChangesLink.toggle( newChangesExist );
- };
-
- module.exports = FilterWrapperWidget;
-}() );
+};
+
+/* Initialization */
+
+OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
+OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );
+
+/* Methods */
+
+/**
+ * Set the content of the top section
+ *
+ * @param {jQuery} $topSectionElement
+ */
+FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
+ this.$top.append( $topSectionElement );
+};
+
+/**
+ * Respond to the user clicking the 'show new changes' button
+ */
+FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
+ this.controller.showNewChanges();
+};
+
+/**
+ * Respond to changes list model newChangesExist
+ *
+ * @param {boolean} newChangesExist Whether new changes exist
+ */
+FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
+ this.showNewChangesLink.toggle( newChangesExist );
+};
+
+module.exports = FilterWrapperWidget;
-( function () {
- /**
- * Wrapper for the RC form with hide/show links
- * Must be constructed after the model is initialized.
- *
- * @class mw.rcfilters.ui.FormWrapperWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
- * @param {mw.rcfilters.Controller} controller RCfilters controller
- * @param {jQuery} $formRoot Root element of the form to attach to
- * @param {Object} config Configuration object
- */
- var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
- config = config || {};
-
- // Parent
- FormWrapperWidget.parent.call( this, $.extend( {}, config, {
- $element: $formRoot
- } ) );
-
- this.changeListModel = changeListModel;
- this.filtersModel = filtersModel;
- this.controller = controller;
- this.$submitButton = this.$element.find( 'form input[type=submit]' );
-
- this.$element
- .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
-
- this.$element
- .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
-
- // Events
- this.changeListModel.connect( this, {
- invalidate: 'onChangesModelInvalidate',
- update: 'onChangesModelUpdate'
- } );
-
- // Initialize
- this.cleanUpFieldset();
- this.$element
- .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
- };
-
- /* Initialization */
-
- OO.inheritClass( FormWrapperWidget, OO.ui.Widget );
-
- /**
- * Respond to link click
- *
- * @param {jQuery.Event} e Event
- * @return {boolean} false
- */
- FormWrapperWidget.prototype.onLinkClick = function ( e ) {
- this.controller.updateChangesList( $( e.target ).data( 'params' ) );
- return false;
- };
-
- /**
- * Respond to form submit event
- *
- * @param {jQuery.Event} e Event
- * @return {boolean} false
- */
- FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
- var data = {};
-
- // Collect all data from form
- $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
- var value = '';
-
- if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
- value = $( this ).val();
- }
-
- data[ $( this ).prop( 'name' ) ] = value;
- } );
-
- this.controller.updateChangesList( data );
- return false;
- };
-
- /**
- * Respond to model invalidate
- */
- FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
- this.$submitButton.prop( 'disabled', true );
- };
-
- /**
- * Respond to model update, replace the show/hide links with the ones from the
- * server so they feature the correct state.
- *
- * @param {jQuery|string} $changesList Updated changes list
- * @param {jQuery} $fieldset Updated fieldset
- * @param {string} noResultsDetails Type of no result error
- * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
- */
- FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
- this.$submitButton.prop( 'disabled', false );
-
- // Replace the entire fieldset
- this.$element.empty().append( $fieldset.contents() );
-
- if ( !isInitialDOM ) {
- // Make sure enhanced RC re-initializes correctly
- mw.hook( 'wikipage.content' ).fire( this.$element );
+/**
+ * Wrapper for the RC form with hide/show links
+ * Must be constructed after the model is initialized.
+ *
+ * @class mw.rcfilters.ui.FormWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
+ * @param {mw.rcfilters.Controller} controller RCfilters controller
+ * @param {jQuery} $formRoot Root element of the form to attach to
+ * @param {Object} config Configuration object
+ */
+var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
+ config = config || {};
+
+ // Parent
+ FormWrapperWidget.parent.call( this, $.extend( {}, config, {
+ $element: $formRoot
+ } ) );
+
+ this.changeListModel = changeListModel;
+ this.filtersModel = filtersModel;
+ this.controller = controller;
+ this.$submitButton = this.$element.find( 'form input[type=submit]' );
+
+ this.$element
+ .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
+
+ this.$element
+ .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
+
+ // Events
+ this.changeListModel.connect( this, {
+ invalidate: 'onChangesModelInvalidate',
+ update: 'onChangesModelUpdate'
+ } );
+
+ // Initialize
+ this.cleanUpFieldset();
+ this.$element
+ .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
+};
+
+/* Initialization */
+
+OO.inheritClass( FormWrapperWidget, OO.ui.Widget );
+
+/**
+ * Respond to link click
+ *
+ * @param {jQuery.Event} e Event
+ * @return {boolean} false
+ */
+FormWrapperWidget.prototype.onLinkClick = function ( e ) {
+ this.controller.updateChangesList( $( e.target ).data( 'params' ) );
+ return false;
+};
+
+/**
+ * Respond to form submit event
+ *
+ * @param {jQuery.Event} e Event
+ * @return {boolean} false
+ */
+FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
+ var data = {};
+
+ // Collect all data from form
+ $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
+ var value = '';
+
+ if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
+ value = $( this ).val();
}
- this.cleanUpFieldset();
- };
-
- /**
- * Clean up the old-style show/hide that we have implemented in the filter list
- */
- FormWrapperWidget.prototype.cleanUpFieldset = function () {
- this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
- // HACK: Remove the text node after the span.
- // If there isn't one, we're at the end, so remove the text node before the span.
- // This would be unnecessary if we added separators with CSS.
- if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
- this.parentNode.removeChild( this.nextSibling );
- } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
- this.parentNode.removeChild( this.previousSibling );
- }
- // Remove the span itself
- this.parentNode.removeChild( this );
- } );
-
- // Hide namespaces and tags
- this.$element.find( '.namespaceForm' ).detach();
- this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
-
- // Hide Related Changes page name form
- this.$element.find( '.targetForm' ).detach();
-
- // misc: limit, days, watchlist info msg
- this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
-
- if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
- this.$element.find( '.mw-recentchanges-table' ).detach();
- this.$element.find( 'hr' ).detach();
+ data[ $( this ).prop( 'name' ) ] = value;
+ } );
+
+ this.controller.updateChangesList( data );
+ return false;
+};
+
+/**
+ * Respond to model invalidate
+ */
+FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
+ this.$submitButton.prop( 'disabled', true );
+};
+
+/**
+ * Respond to model update, replace the show/hide links with the ones from the
+ * server so they feature the correct state.
+ *
+ * @param {jQuery|string} $changesList Updated changes list
+ * @param {jQuery} $fieldset Updated fieldset
+ * @param {string} noResultsDetails Type of no result error
+ * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+ */
+FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
+ this.$submitButton.prop( 'disabled', false );
+
+ // Replace the entire fieldset
+ this.$element.empty().append( $fieldset.contents() );
+
+ if ( !isInitialDOM ) {
+ // Make sure enhanced RC re-initializes correctly
+ mw.hook( 'wikipage.content' ).fire( this.$element );
+ }
+
+ this.cleanUpFieldset();
+};
+
+/**
+ * Clean up the old-style show/hide that we have implemented in the filter list
+ */
+FormWrapperWidget.prototype.cleanUpFieldset = function () {
+ this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
+ // HACK: Remove the text node after the span.
+ // If there isn't one, we're at the end, so remove the text node before the span.
+ // This would be unnecessary if we added separators with CSS.
+ if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
+ this.parentNode.removeChild( this.nextSibling );
+ } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
+ this.parentNode.removeChild( this.previousSibling );
}
-
- // Get rid of all <br>s, which are inside rcshowhide
- // If we still have content in rcshowhide, the <br>s are
- // gone. Instead, the CSS now has a rule to mark all <span>s
- // inside .rcshowhide with display:block; to simulate newlines
- // where they're actually needed.
- this.$element.find( 'br' ).detach();
- if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
- this.$element.find( '.rcshowhide' ).detach();
- }
-
- if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
- this.$element.find( '.cloption-submit' ).detach();
- }
-
- this.$element.find(
- '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
- ).detach();
-
- // Get rid of the legend
- this.$element.find( 'legend' ).detach();
-
- // Check if the element is essentially empty, and detach it if it is
- if ( !this.$element.text().trim().length ) {
- this.$element.detach();
- }
- };
-
- module.exports = FormWrapperWidget;
-}() );
+ // Remove the span itself
+ this.parentNode.removeChild( this );
+ } );
+
+ // Hide namespaces and tags
+ this.$element.find( '.namespaceForm' ).detach();
+ this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
+
+ // Hide Related Changes page name form
+ this.$element.find( '.targetForm' ).detach();
+
+ // misc: limit, days, watchlist info msg
+ this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
+
+ if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
+ this.$element.find( '.mw-recentchanges-table' ).detach();
+ this.$element.find( 'hr' ).detach();
+ }
+
+ // Get rid of all <br>s, which are inside rcshowhide
+ // If we still have content in rcshowhide, the <br>s are
+ // gone. Instead, the CSS now has a rule to mark all <span>s
+ // inside .rcshowhide with display:block; to simulate newlines
+ // where they're actually needed.
+ this.$element.find( 'br' ).detach();
+ if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
+ this.$element.find( '.rcshowhide' ).detach();
+ }
+
+ if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
+ this.$element.find( '.cloption-submit' ).detach();
+ }
+
+ this.$element.find(
+ '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
+ ).detach();
+
+ // Get rid of the legend
+ this.$element.find( 'legend' ).detach();
+
+ // Check if the element is essentially empty, and detach it if it is
+ if ( !this.$element.text().trim().length ) {
+ this.$element.detach();
+ }
+};
+
+module.exports = FormWrapperWidget;
-( function () {
- /**
- * A group widget to allow for aggregation of events
- *
- * @class mw.rcfilters.ui.GroupWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {Object} [config] Configuration object
- * @param {Object} [events] Events to aggregate. The object represent the
- * event name to aggregate and the event value to emit on aggregate for items.
- */
- var GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
- var aggregate = {};
-
- config = config || {};
-
- // Parent constructor
- GroupWidget.parent.call( this, config );
-
- // Mixin constructors
- OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
- if ( config.events ) {
- // Aggregate events
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( config.events, function ( eventName, eventEmit ) {
- aggregate[ eventName ] = eventEmit;
- } );
-
- this.aggregate( aggregate );
- }
-
- if ( Array.isArray( config.items ) ) {
- this.addItems( config.items );
- }
- };
-
- /* Initialize */
-
- OO.inheritClass( GroupWidget, OO.ui.Widget );
- OO.mixinClass( GroupWidget, OO.ui.mixin.GroupWidget );
-
- module.exports = GroupWidget;
-}() );
+/**
+ * A group widget to allow for aggregation of events
+ *
+ * @class mw.rcfilters.ui.GroupWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration object
+ * @param {Object} [events] Events to aggregate. The object represent the
+ * event name to aggregate and the event value to emit on aggregate for items.
+ */
+var GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
+ var aggregate = {};
+
+ config = config || {};
+
+ // Parent constructor
+ GroupWidget.parent.call( this, config );
+
+ // Mixin constructors
+ OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+ if ( config.events ) {
+ // Aggregate events
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( config.events, function ( eventName, eventEmit ) {
+ aggregate[ eventName ] = eventEmit;
+ } );
+
+ this.aggregate( aggregate );
+ }
+
+ if ( Array.isArray( config.items ) ) {
+ this.addItems( config.items );
+ }
+};
+
+/* Initialize */
+
+OO.inheritClass( GroupWidget, OO.ui.Widget );
+OO.mixinClass( GroupWidget, OO.ui.mixin.GroupWidget );
+
+module.exports = GroupWidget;
-( function () {
- /**
- * A widget representing a filter item highlight color picker
- *
- * @class mw.rcfilters.ui.HighlightColorPickerWidget
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {Object} [config] Configuration object
- */
- var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
- var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
- config = config || {};
-
- // Parent
- HighlightColorPickerWidget.parent.call( this, config );
- // Mixin constructors
- OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
- label: mw.message( 'rcfilters-highlightmenu-title' ).text()
- } ) );
-
- this.controller = controller;
-
- this.currentSelection = 'none';
- this.buttonSelect = new OO.ui.ButtonSelectWidget( {
- items: colors.map( function ( color ) {
- return new OO.ui.ButtonOptionWidget( {
- icon: color === 'none' ? 'check' : null,
- data: color,
- classes: [
- 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
- 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
- ],
- framed: false
- } );
- } ),
- classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
- } );
-
- // Event
- this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
- .append(
- this.$label
- .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
- this.buttonSelect.$element
- );
- };
-
- /* Initialization */
-
- OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
- OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
-
- /* Events */
-
- /**
- * @event chooseColor
- * @param {string} The chosen color
- *
- * A color has been chosen
- */
-
- /* Methods */
-
- /**
- * Bind the color picker to an item
- * @param {mw.rcfilters.dm.FilterItem} filterItem
- */
- HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
- if ( this.filterItem ) {
- this.filterItem.disconnect( this );
+/**
+ * A widget representing a filter item highlight color picker
+ *
+ * @class mw.rcfilters.ui.HighlightColorPickerWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {Object} [config] Configuration object
+ */
+var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
+ var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
+ config = config || {};
+
+ // Parent
+ HighlightColorPickerWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+ label: mw.message( 'rcfilters-highlightmenu-title' ).text()
+ } ) );
+
+ this.controller = controller;
+
+ this.currentSelection = 'none';
+ this.buttonSelect = new OO.ui.ButtonSelectWidget( {
+ items: colors.map( function ( color ) {
+ return new OO.ui.ButtonOptionWidget( {
+ icon: color === 'none' ? 'check' : null,
+ data: color,
+ classes: [
+ 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
+ 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
+ ],
+ framed: false
+ } );
+ } ),
+ classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
+ } );
+
+ // Event
+ this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
+ .append(
+ this.$label
+ .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
+ this.buttonSelect.$element
+ );
+};
+
+/* Initialization */
+
+OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
+OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
+
+/* Events */
+
+/**
+ * @event chooseColor
+ * @param {string} The chosen color
+ *
+ * A color has been chosen
+ */
+
+/* Methods */
+
+/**
+ * Bind the color picker to an item
+ * @param {mw.rcfilters.dm.FilterItem} filterItem
+ */
+HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
+ if ( this.filterItem ) {
+ this.filterItem.disconnect( this );
+ }
+
+ this.filterItem = filterItem;
+ this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
+ this.updateUiBasedOnModel();
+};
+
+/**
+ * Respond to item model update event
+ */
+HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
+ this.selectColor( this.filterItem.getHighlightColor() || 'none' );
+};
+
+/**
+ * Select the color for this widget
+ *
+ * @param {string} color Selected color
+ */
+HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
+ var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
+ selectedItem = this.buttonSelect.findItemFromData( color );
+
+ if ( this.currentSelection !== color ) {
+ this.currentSelection = color;
+
+ this.buttonSelect.selectItem( selectedItem );
+ if ( previousItem ) {
+ previousItem.setIcon( null );
}
- this.filterItem = filterItem;
- this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
- this.updateUiBasedOnModel();
- };
-
- /**
- * Respond to item model update event
- */
- HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
- this.selectColor( this.filterItem.getHighlightColor() || 'none' );
- };
-
- /**
- * Select the color for this widget
- *
- * @param {string} color Selected color
- */
- HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
- var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
- selectedItem = this.buttonSelect.findItemFromData( color );
-
- if ( this.currentSelection !== color ) {
- this.currentSelection = color;
-
- this.buttonSelect.selectItem( selectedItem );
- if ( previousItem ) {
- previousItem.setIcon( null );
- }
-
- if ( selectedItem ) {
- selectedItem.setIcon( 'check' );
- }
+ if ( selectedItem ) {
+ selectedItem.setIcon( 'check' );
}
- };
-
- HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
- var color = button.data;
- if ( color === 'none' ) {
- this.controller.clearHighlightColor( this.filterItem.getName() );
- } else {
- this.controller.setHighlightColor( this.filterItem.getName(), color );
- }
- this.emit( 'chooseColor', color );
- };
-
- module.exports = HighlightColorPickerWidget;
-}() );
+ }
+};
+
+HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
+ var color = button.data;
+ if ( color === 'none' ) {
+ this.controller.clearHighlightColor( this.filterItem.getName() );
+ } else {
+ this.controller.setHighlightColor( this.filterItem.getName(), color );
+ }
+ this.emit( 'chooseColor', color );
+};
+
+module.exports = HighlightColorPickerWidget;
-( function () {
- var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
- HighlightPopupWidget;
- /**
- * A popup containing a color picker, for setting highlight colors.
- *
- * @class mw.rcfilters.ui.HighlightPopupWidget
- * @extends OO.ui.PopupWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {Object} [config] Configuration object
- */
- HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
- config = config || {};
+var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
+ HighlightPopupWidget;
+/**
+ * A popup containing a color picker, for setting highlight colors.
+ *
+ * @class mw.rcfilters.ui.HighlightPopupWidget
+ * @extends OO.ui.PopupWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {Object} [config] Configuration object
+ */
+HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
+ config = config || {};
- // Parent
- HighlightPopupWidget.parent.call( this, $.extend( {
- autoClose: true,
- anchor: false,
- padded: true,
- align: 'backwards',
- horizontalPosition: 'end',
- width: 290
- }, config ) );
+ // Parent
+ HighlightPopupWidget.parent.call( this, $.extend( {
+ autoClose: true,
+ anchor: false,
+ padded: true,
+ align: 'backwards',
+ horizontalPosition: 'end',
+ width: 290
+ }, config ) );
- this.colorPicker = new HighlightColorPickerWidget( controller );
+ this.colorPicker = new HighlightColorPickerWidget( controller );
- this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
+ this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
- this.$body.append( this.colorPicker.$element );
- };
+ this.$body.append( this.colorPicker.$element );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
+OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
- /* Methods */
+/* Methods */
- /**
- * Set the button (or other widget) that this popup should hang off.
- *
- * @param {OO.ui.Widget} widget Widget the popup should orient itself to
- */
- HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
- this.setFloatableContainer( widget.$element );
- this.$autoCloseIgnore = widget.$element;
- };
+/**
+ * Set the button (or other widget) that this popup should hang off.
+ *
+ * @param {OO.ui.Widget} widget Widget the popup should orient itself to
+ */
+HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
+ this.setFloatableContainer( widget.$element );
+ this.$autoCloseIgnore = widget.$element;
+};
- /**
- * Set the filter item that this popup should control the highlight color for.
- *
- * @param {mw.rcfilters.dm.FilterItem} item
- */
- HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
- this.colorPicker.setFilterItem( item );
- };
+/**
+ * Set the filter item that this popup should control the highlight color for.
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item
+ */
+HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
+ this.colorPicker.setFilterItem( item );
+};
- /**
- * When the user chooses a color in the color picker, close the popup.
- */
- HighlightPopupWidget.prototype.onChooseColor = function () {
- this.toggle( false );
- };
+/**
+ * When the user chooses a color in the color picker, close the popup.
+ */
+HighlightPopupWidget.prototype.onChooseColor = function () {
+ this.toggle( false );
+};
- module.exports = HighlightPopupWidget;
-
-}() );
+module.exports = HighlightPopupWidget;
-( function () {
- var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
- CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
- ItemMenuOptionWidget;
-
- /**
- * A widget representing a base toggle item
- *
- * @class mw.rcfilters.ui.ItemMenuOptionWidget
- * @extends OO.ui.MenuOptionWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller RCFilters controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.ItemModel} invertModel
- * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
- * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
- * @param {Object} config Configuration object
- */
- ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
- controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
- ) {
- var layout,
- classes = [],
- $label = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
-
- config = config || {};
-
- this.controller = controller;
- this.filtersViewModel = filtersViewModel;
- this.invertModel = invertModel;
- this.itemModel = itemModel;
-
- // Parent
- ItemMenuOptionWidget.parent.call( this, $.extend( {
- // Override the 'check' icon that OOUI defines
- icon: '',
- data: this.itemModel.getName(),
- label: this.itemModel.getLabel()
- }, config ) );
-
- this.checkboxWidget = new CheckboxInputWidget( {
- value: this.itemModel.getName(),
- selected: this.itemModel.isSelected()
- } );
-
+var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
+ CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
+ ItemMenuOptionWidget;
+
+/**
+ * A widget representing a base toggle item
+ *
+ * @class mw.rcfilters.ui.ItemMenuOptionWidget
+ * @extends OO.ui.MenuOptionWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.ItemModel} invertModel
+ * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
+ * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+ * @param {Object} config Configuration object
+ */
+ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
+ controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+) {
+ var layout,
+ classes = [],
+ $label = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.filtersViewModel = filtersViewModel;
+ this.invertModel = invertModel;
+ this.itemModel = itemModel;
+
+ // Parent
+ ItemMenuOptionWidget.parent.call( this, $.extend( {
+ // Override the 'check' icon that OOUI defines
+ icon: '',
+ data: this.itemModel.getName(),
+ label: this.itemModel.getLabel()
+ }, config ) );
+
+ this.checkboxWidget = new CheckboxInputWidget( {
+ value: this.itemModel.getName(),
+ selected: this.itemModel.isSelected()
+ } );
+
+ $label.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
+ .append( $( '<bdi>' ).append( this.$label ) )
+ );
+ if ( this.itemModel.getDescription() ) {
$label.append(
$( '<div>' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
- .append( $( '<bdi>' ).append( this.$label ) )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
+ .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
);
- if ( this.itemModel.getDescription() ) {
- $label.append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
- .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
- );
+ }
+
+ this.highlightButton = new FilterItemHighlightButton(
+ this.controller,
+ this.itemModel,
+ highlightPopup,
+ {
+ $overlay: config.$overlay || this.$element,
+ title: mw.msg( 'rcfilters-highlightmenu-help' )
}
-
- this.highlightButton = new FilterItemHighlightButton(
- this.controller,
- this.itemModel,
- highlightPopup,
- {
- $overlay: config.$overlay || this.$element,
- title: mw.msg( 'rcfilters-highlightmenu-help' )
- }
- );
- this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-
- this.excludeLabel = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-filter-excluded' )
- } );
- this.excludeLabel.toggle(
- this.itemModel.getGroupModel().getView() === 'namespaces' &&
- this.itemModel.isSelected() &&
- this.invertModel.isSelected()
+ );
+ this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+
+ this.excludeLabel = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-filter-excluded' )
+ } );
+ this.excludeLabel.toggle(
+ this.itemModel.getGroupModel().getView() === 'namespaces' &&
+ this.itemModel.isSelected() &&
+ this.invertModel.isSelected()
+ );
+
+ layout = new OO.ui.FieldLayout( this.checkboxWidget, {
+ label: $label,
+ align: 'inline'
+ } );
+
+ // Events
+ this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+ this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+ this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+ // HACK: Prevent defaults on 'click' for the label so it
+ // doesn't steal the focus away from the input. This means
+ // we can continue arrow-movement after we click the label
+ // and is consistent with the checkbox *itself* also preventing
+ // defaults on 'click' as well.
+ layout.$label.on( 'click', false );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
+ .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
+ .append( layout.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
+ .append( this.excludeLabel.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
+ .append( this.highlightButton.$element )
+ )
+ )
);
- layout = new OO.ui.FieldLayout( this.checkboxWidget, {
- label: $label,
- align: 'inline'
+ if ( this.itemModel.getIdentifiers() ) {
+ this.itemModel.getIdentifiers().forEach( function ( ident ) {
+ classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
} );
- // Events
- this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
- this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
- this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
- // HACK: Prevent defaults on 'click' for the label so it
- // doesn't steal the focus away from the input. This means
- // we can continue arrow-movement after we click the label
- // and is consistent with the checkbox *itself* also preventing
- // defaults on 'click' as well.
- layout.$label.on( 'click', false );
-
- this.$element
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
- .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
- .append( layout.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
- .append( this.excludeLabel.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
- .append( this.highlightButton.$element )
- )
- )
- );
-
- if ( this.itemModel.getIdentifiers() ) {
- this.itemModel.getIdentifiers().forEach( function ( ident ) {
- classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
- } );
-
- this.$element.addClass( classes );
- }
+ this.$element.addClass( classes );
+ }
- this.updateUiBasedOnState();
- };
+ this.updateUiBasedOnState();
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
+OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
- /* Static properties */
+/* Static properties */
- // We do our own scrolling to top
- ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+// We do our own scrolling to top
+ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
- /* Methods */
+/* Methods */
- /**
- * Respond to item model update event
- */
- ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
- this.checkboxWidget.setSelected( this.itemModel.isSelected() );
-
- this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
- this.excludeLabel.toggle(
- this.itemModel.getGroupModel().getView() === 'namespaces' &&
- this.itemModel.isSelected() &&
- this.invertModel.isSelected()
- );
- this.toggle( this.itemModel.isVisible() );
- };
+/**
+ * Respond to item model update event
+ */
+ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+ this.checkboxWidget.setSelected( this.itemModel.isSelected() );
- /**
- * Get the name of this filter
- *
- * @return {string} Filter name
- */
- ItemMenuOptionWidget.prototype.getName = function () {
- return this.itemModel.getName();
- };
+ this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+ this.excludeLabel.toggle(
+ this.itemModel.getGroupModel().getView() === 'namespaces' &&
+ this.itemModel.isSelected() &&
+ this.invertModel.isSelected()
+ );
+ this.toggle( this.itemModel.isVisible() );
+};
- ItemMenuOptionWidget.prototype.getModel = function () {
- return this.itemModel;
- };
+/**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ItemMenuOptionWidget.prototype.getName = function () {
+ return this.itemModel.getName();
+};
- module.exports = ItemMenuOptionWidget;
+ItemMenuOptionWidget.prototype.getModel = function () {
+ return this.itemModel;
+};
-}() );
+module.exports = ItemMenuOptionWidget;
-( function () {
- /**
- * Widget for toggling live updates
- *
- * @class mw.rcfilters.ui.LiveUpdateButtonWidget
- * @extends OO.ui.ToggleButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {Object} [config] Configuration object
- */
- var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
- config = config || {};
+/**
+ * Widget for toggling live updates
+ *
+ * @class mw.rcfilters.ui.LiveUpdateButtonWidget
+ * @extends OO.ui.ToggleButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} [config] Configuration object
+ */
+var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
+ config = config || {};
- // Parent
- LiveUpdateButtonWidget.parent.call( this, $.extend( {
- label: mw.message( 'rcfilters-liveupdates-button' ).text()
- }, config ) );
+ // Parent
+ LiveUpdateButtonWidget.parent.call( this, $.extend( {
+ label: mw.message( 'rcfilters-liveupdates-button' ).text()
+ }, config ) );
- this.controller = controller;
- this.model = changesListModel;
+ this.controller = controller;
+ this.model = changesListModel;
- // Events
- this.connect( this, { click: 'onClick' } );
- this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
+ // Events
+ this.connect( this, { click: 'onClick' } );
+ this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
- this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
+ this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
- this.setState( false );
- };
+ this.setState( false );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
+OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
- /* Methods */
+/* Methods */
- /**
- * Respond to the button being clicked
- */
- LiveUpdateButtonWidget.prototype.onClick = function () {
- this.controller.toggleLiveUpdate();
- };
+/**
+ * Respond to the button being clicked
+ */
+LiveUpdateButtonWidget.prototype.onClick = function () {
+ this.controller.toggleLiveUpdate();
+};
- /**
- * Set the button's state and change its appearance
- *
- * @param {boolean} enable Whether the 'live update' feature is now on/off
- */
- LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
- this.setValue( enable );
- this.setIcon( enable ? 'stop' : 'play' );
- this.setTitle( mw.message(
- enable ?
- 'rcfilters-liveupdates-button-title-on' :
- 'rcfilters-liveupdates-button-title-off'
- ).text() );
- };
+/**
+ * Set the button's state and change its appearance
+ *
+ * @param {boolean} enable Whether the 'live update' feature is now on/off
+ */
+LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
+ this.setValue( enable );
+ this.setIcon( enable ? 'stop' : 'play' );
+ this.setTitle( mw.message(
+ enable ?
+ 'rcfilters-liveupdates-button-title-on' :
+ 'rcfilters-liveupdates-button-title-off'
+ ).text() );
+};
- /**
- * Respond to the 'live update' feature being turned on/off
- *
- * @param {boolean} enable Whether the 'live update' feature is now on/off
- */
- LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
- this.setState( enable );
- };
+/**
+ * Respond to the 'live update' feature being turned on/off
+ *
+ * @param {boolean} enable Whether the 'live update' feature is now on/off
+ */
+LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
+ this.setState( enable );
+};
- module.exports = LiveUpdateButtonWidget;
-
-}() );
+module.exports = LiveUpdateButtonWidget;
-( function () {
- var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
- FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
- ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
- RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
- RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
- WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
- FormWrapperWidget = require( './FormWrapperWidget.js' ),
- MainWrapperWidget;
-
- /**
- * Wrapper for changes list content
- *
- * @class mw.rcfilters.ui.MainWrapperWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {Object} config Configuration object
- * @cfg {jQuery} $topSection Top section container
- * @cfg {jQuery} $filtersContainer
- * @cfg {jQuery} $changesListContainer
- * @cfg {jQuery} $formContainer
- * @cfg {boolean} [collapsed] Filter area is collapsed
- * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
- * system. If not given, falls back to this widget's $element
- */
- MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
- controller, model, savedQueriesModel, changesListModel, config
- ) {
- config = $.extend( {}, config );
-
- // Parent
- MainWrapperWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = model;
- this.changesListModel = changesListModel;
- this.$topSection = config.$topSection;
- this.$filtersContainer = config.$filtersContainer;
- this.$changesListContainer = config.$changesListContainer;
- this.$formContainer = config.$formContainer;
- this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
- this.$wrapper = config.$wrapper || this.$element;
-
- this.savedLinksListWidget = new SavedLinksListWidget(
- controller, savedQueriesModel, { $overlay: this.$overlay }
- );
-
- this.filtersWidget = new FilterWrapperWidget(
- controller,
- model,
- savedQueriesModel,
- changesListModel,
- {
- $overlay: this.$overlay,
- $wrapper: this.$wrapper,
- collapsed: config.collapsed
- }
- );
-
- this.changesListWidget = new ChangesListWrapperWidget(
- model, changesListModel, controller, this.$changesListContainer );
+var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
+ FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
+ ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
+ RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
+ RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
+ WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
+ FormWrapperWidget = require( './FormWrapperWidget.js' ),
+ MainWrapperWidget;
+
+/**
+ * Wrapper for changes list content
+ *
+ * @class mw.rcfilters.ui.MainWrapperWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} $topSection Top section container
+ * @cfg {jQuery} $filtersContainer
+ * @cfg {jQuery} $changesListContainer
+ * @cfg {jQuery} $formContainer
+ * @cfg {boolean} [collapsed] Filter area is collapsed
+ * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+ * system. If not given, falls back to this widget's $element
+ */
+MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
+ controller, model, savedQueriesModel, changesListModel, config
+) {
+ config = $.extend( {}, config );
+
+ // Parent
+ MainWrapperWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+ this.changesListModel = changesListModel;
+ this.$topSection = config.$topSection;
+ this.$filtersContainer = config.$filtersContainer;
+ this.$changesListContainer = config.$changesListContainer;
+ this.$formContainer = config.$formContainer;
+ this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
+ this.$wrapper = config.$wrapper || this.$element;
+
+ this.savedLinksListWidget = new SavedLinksListWidget(
+ controller, savedQueriesModel, { $overlay: this.$overlay }
+ );
+
+ this.filtersWidget = new FilterWrapperWidget(
+ controller,
+ model,
+ savedQueriesModel,
+ changesListModel,
+ {
+ $overlay: this.$overlay,
+ $wrapper: this.$wrapper,
+ collapsed: config.collapsed
+ }
+ );
- /* Events */
+ this.changesListWidget = new ChangesListWrapperWidget(
+ model, changesListModel, controller, this.$changesListContainer );
- // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
- // to prevent users from accidentally clicking on links in results, while menu is opened.
- // Overlay on changes list is not the same as this.$overlay
- this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
+ /* Events */
- // Initialize
- this.$filtersContainer.append( this.filtersWidget.$element );
- $( 'body' )
- .append( this.$overlay )
- .addClass( 'mw-rcfilters-ui-initialized' );
- };
+ // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
+ // to prevent users from accidentally clicking on links in results, while menu is opened.
+ // Overlay on changes list is not the same as this.$overlay
+ this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
- /* Initialization */
+ // Initialize
+ this.$filtersContainer.append( this.filtersWidget.$element );
+ $( 'body' )
+ .append( this.$overlay )
+ .addClass( 'mw-rcfilters-ui-initialized' );
+};
- OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
+/* Initialization */
- /* Methods */
+OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
- /**
- * Set the content of the top section, depending on the type of special page.
- *
- * @param {string} specialPage
- */
- MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
- var topSection;
+/* Methods */
- if ( specialPage === 'Recentchanges' ) {
- topSection = new RcTopSectionWidget(
- this.savedLinksListWidget, this.$topSection
- );
- this.filtersWidget.setTopSection( topSection.$element );
- }
+/**
+ * Set the content of the top section, depending on the type of special page.
+ *
+ * @param {string} specialPage
+ */
+MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
+ var topSection;
- if ( specialPage === 'Recentchangeslinked' ) {
- topSection = new RclTopSectionWidget(
- this.savedLinksListWidget, this.controller,
- this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
- this.model.getGroup( 'page' ).getItemByParamName( 'target' )
- );
+ if ( specialPage === 'Recentchanges' ) {
+ topSection = new RcTopSectionWidget(
+ this.savedLinksListWidget, this.$topSection
+ );
+ this.filtersWidget.setTopSection( topSection.$element );
+ }
+
+ if ( specialPage === 'Recentchangeslinked' ) {
+ topSection = new RclTopSectionWidget(
+ this.savedLinksListWidget, this.controller,
+ this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
+ this.model.getGroup( 'page' ).getItemByParamName( 'target' )
+ );
- this.filtersWidget.setTopSection( topSection.$element );
- }
+ this.filtersWidget.setTopSection( topSection.$element );
+ }
- if ( specialPage === 'Watchlist' ) {
- topSection = new WatchlistTopSectionWidget(
- this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
- );
+ if ( specialPage === 'Watchlist' ) {
+ topSection = new WatchlistTopSectionWidget(
+ this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
+ );
- this.filtersWidget.setTopSection( topSection.$element );
- }
- };
-
- /**
- * Filter menu toggle event listener
- *
- * @param {boolean} isVisible
- */
- MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
- this.changesListWidget.toggleOverlay( isVisible );
- };
-
- /**
- * Initialize FormWrapperWidget
- *
- * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
- */
- MainWrapperWidget.prototype.initFormWidget = function () {
- return new FormWrapperWidget(
- this.model, this.changesListModel, this.controller, this.$formContainer );
- };
-
- module.exports = MainWrapperWidget;
-}() );
+ this.filtersWidget.setTopSection( topSection.$element );
+ }
+};
+
+/**
+ * Filter menu toggle event listener
+ *
+ * @param {boolean} isVisible
+ */
+MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
+ this.changesListWidget.toggleOverlay( isVisible );
+};
+
+/**
+ * Initialize FormWrapperWidget
+ *
+ * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
+ */
+MainWrapperWidget.prototype.initFormWidget = function () {
+ return new FormWrapperWidget(
+ this.model, this.changesListModel, this.controller, this.$formContainer );
+};
+
+module.exports = MainWrapperWidget;
-( function () {
- /**
- * Button for marking all changes as seen on the Watchlist
- *
- * @class mw.rcfilters.ui.MarkSeenButtonWidget
- * @extends OO.ui.ButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
- * @param {Object} [config] Configuration object
- */
- var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
- config = config || {};
-
- // Parent
- MarkSeenButtonWidget.parent.call( this, $.extend( {
- label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
- icon: 'checkAll'
- }, config ) );
-
- this.controller = controller;
- this.model = model;
-
- // Events
- this.connect( this, { click: 'onClick' } );
- this.model.connect( this, { update: 'onModelUpdate' } );
-
- this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
-
- this.onModelUpdate();
- };
-
- /* Initialization */
-
- OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );
-
- /* Methods */
-
- /**
- * Respond to the button being clicked
- */
- MarkSeenButtonWidget.prototype.onClick = function () {
- this.controller.markAllChangesAsSeen();
- // assume there's no more unseen changes until the next model update
- this.setDisabled( true );
- };
-
- /**
- * Respond to the model being updated with new changes
- */
- MarkSeenButtonWidget.prototype.onModelUpdate = function () {
- this.setDisabled( !this.model.hasUnseenWatchedChanges() );
- };
-
- module.exports = MarkSeenButtonWidget;
-
-}() );
+/**
+ * Button for marking all changes as seen on the Watchlist
+ *
+ * @class mw.rcfilters.ui.MarkSeenButtonWidget
+ * @extends OO.ui.ButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
+ * @param {Object} [config] Configuration object
+ */
+var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
+ config = config || {};
+
+ // Parent
+ MarkSeenButtonWidget.parent.call( this, $.extend( {
+ label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
+ icon: 'checkAll'
+ }, config ) );
+
+ this.controller = controller;
+ this.model = model;
+
+ // Events
+ this.connect( this, { click: 'onClick' } );
+ this.model.connect( this, { update: 'onModelUpdate' } );
+
+ this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
+
+ this.onModelUpdate();
+};
+
+/* Initialization */
+
+OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );
+
+/* Methods */
+
+/**
+ * Respond to the button being clicked
+ */
+MarkSeenButtonWidget.prototype.onClick = function () {
+ this.controller.markAllChangesAsSeen();
+ // assume there's no more unseen changes until the next model update
+ this.setDisabled( true );
+};
+
+/**
+ * Respond to the model being updated with new changes
+ */
+MarkSeenButtonWidget.prototype.onModelUpdate = function () {
+ this.setDisabled( !this.model.hasUnseenWatchedChanges() );
+};
+
+module.exports = MarkSeenButtonWidget;
-( function () {
- var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
- HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
- FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
- FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
- MenuSelectWidget;
-
- /**
- * A floating menu widget for the filter list
- *
- * @class mw.rcfilters.ui.MenuSelectWidget
- * @extends OO.ui.MenuSelectWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {Object} [config] Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- * @cfg {Object[]} [footers] An array of objects defining the footers for
- * this menu, with a definition whether they appear per specific views.
- * The expected structure is:
- * [
- * {
- * name: {string} A unique name for the footer object
- * $element: {jQuery} A jQuery object for the content of the footer
- * views: {string[]} Optional. An array stating which views this footer is
- * active on. Use null or omit to display this on all views.
- * }
- * ]
- */
- MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
- var header;
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
- this.currentView = '';
- this.views = {};
- this.userSelecting = false;
-
- this.menuInitialized = false;
- this.$overlay = config.$overlay || this.$element;
- this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
- this.footers = [];
-
- // Parent
- MenuSelectWidget.parent.call( this, $.extend( config, {
- $autoCloseIgnore: this.$overlay,
- width: 650,
- // Our filtering is done through the model
- filterFromInput: false
- } ) );
- this.setGroupElement(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
- );
- this.setClippableElement( this.$body );
- this.setClippableContainer( this.$element );
-
- header = new FilterMenuHeaderWidget(
- this.controller,
- this.model,
- {
- $overlay: this.$overlay
- }
+var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
+ HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
+ FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
+ FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
+ MenuSelectWidget;
+
+/**
+ * A floating menu widget for the filter list
+ *
+ * @class mw.rcfilters.ui.MenuSelectWidget
+ * @extends OO.ui.MenuSelectWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ * @cfg {Object[]} [footers] An array of objects defining the footers for
+ * this menu, with a definition whether they appear per specific views.
+ * The expected structure is:
+ * [
+ * {
+ * name: {string} A unique name for the footer object
+ * $element: {jQuery} A jQuery object for the content of the footer
+ * views: {string[]} Optional. An array stating which views this footer is
+ * active on. Use null or omit to display this on all views.
+ * }
+ * ]
+ */
+MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
+ var header;
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+ this.currentView = '';
+ this.views = {};
+ this.userSelecting = false;
+
+ this.menuInitialized = false;
+ this.$overlay = config.$overlay || this.$element;
+ this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
+ this.footers = [];
+
+ // Parent
+ MenuSelectWidget.parent.call( this, $.extend( config, {
+ $autoCloseIgnore: this.$overlay,
+ width: 650,
+ // Our filtering is done through the model
+ filterFromInput: false
+ } ) );
+ this.setGroupElement(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
+ );
+ this.setClippableElement( this.$body );
+ this.setClippableContainer( this.$element );
+
+ header = new FilterMenuHeaderWidget(
+ this.controller,
+ this.model,
+ {
+ $overlay: this.$overlay
+ }
+ );
+
+ this.noResults = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-filterlist-noresults' ),
+ classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
+ } );
+
+ // Events
+ this.model.connect( this, {
+ initialize: 'onModelInitialize',
+ searchChange: 'onModelSearchChange'
+ } );
+
+ // Initialization
+ this.$element
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
+ .append( header.$element )
+ .append(
+ this.$body
+ .append( this.$group, this.noResults.$element )
);
- this.noResults = new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-filterlist-noresults' ),
- classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
- } );
-
- // Events
- this.model.connect( this, {
- initialize: 'onModelInitialize',
- searchChange: 'onModelSearchChange'
- } );
-
- // Initialization
- this.$element
- .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
- .append( header.$element )
- .append(
- this.$body
- .append( this.$group, this.noResults.$element )
- );
-
- // Append all footers; we will control their visibility
- // based on view
- config.footers = config.footers || [];
- config.footers.forEach( function ( footerData ) {
- var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
- adjustedData = {
- // Wrap the element with our own footer wrapper
- $element: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
- .append( footerData.$element ),
- views: footerData.views
- };
-
- if ( !footerData.disabled ) {
- this.footers.push( adjustedData );
-
- if ( isSticky ) {
- this.$element.append( adjustedData.$element );
- } else {
- this.$body.append( adjustedData.$element );
- }
+ // Append all footers; we will control their visibility
+ // based on view
+ config.footers = config.footers || [];
+ config.footers.forEach( function ( footerData ) {
+ var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
+ adjustedData = {
+ // Wrap the element with our own footer wrapper
+ $element: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
+ .append( footerData.$element ),
+ views: footerData.views
+ };
+
+ if ( !footerData.disabled ) {
+ this.footers.push( adjustedData );
+
+ if ( isSticky ) {
+ this.$element.append( adjustedData.$element );
+ } else {
+ this.$body.append( adjustedData.$element );
}
- }.bind( this ) );
-
- // Switch to the correct view
- this.updateView();
- };
-
- /* Initialize */
-
- OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
-
- /* Events */
-
- /* Methods */
- MenuSelectWidget.prototype.onModelSearchChange = function () {
- this.updateView();
- };
-
- /**
- * @inheritdoc
- */
- MenuSelectWidget.prototype.toggle = function ( show ) {
- this.lazyMenuCreation();
- MenuSelectWidget.parent.prototype.toggle.call( this, show );
- // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
- this.setVerticalPosition( 'below' );
- };
-
- /**
- * lazy creation of the menu
- */
- MenuSelectWidget.prototype.lazyMenuCreation = function () {
- var widget = this,
- items = [],
- viewGroupCount = {},
- groups = this.model.getFilterGroups();
-
- if ( this.menuInitialized ) {
- return;
}
-
- this.menuInitialized = true;
-
- // Create shared popup for highlight buttons
- this.highlightPopup = new HighlightPopupWidget( this.controller );
- this.$overlay.append( this.highlightPopup.$element );
-
- // Count groups per view
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( groups, function ( groupName, groupModel ) {
- if ( !groupModel.isHidden() ) {
- viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
- viewGroupCount[ groupModel.getView() ]++;
- }
- } );
-
- // eslint-disable-next-line no-jquery/no-each-util
- $.each( groups, function ( groupName, groupModel ) {
- var currentItems = [],
- view = groupModel.getView();
-
- if ( !groupModel.isHidden() ) {
- if ( viewGroupCount[ view ] > 1 ) {
- // Only add a section header if there is more than
- // one group
- currentItems.push(
- // Group section
- new FilterMenuSectionOptionWidget(
- widget.controller,
- groupModel,
- {
- $overlay: widget.$overlay
- }
- )
- );
- }
-
- // Add items
- widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
- currentItems.push(
- new FilterMenuOptionWidget(
- widget.controller,
- widget.model,
- widget.model.getInvertModel(),
- filterItem,
- widget.highlightPopup,
- {
- $overlay: widget.$overlay
- }
- )
- );
- } );
-
- // Cache the items per view, so we can switch between them
- // without rebuilding the widgets each time
- widget.views[ view ] = widget.views[ view ] || [];
- widget.views[ view ] = widget.views[ view ].concat( currentItems );
- items = items.concat( currentItems );
- }
- } );
-
- this.addItems( items );
- this.updateView();
- };
-
- /**
- * Respond to model initialize event. Populate the menu from the model
- */
- MenuSelectWidget.prototype.onModelInitialize = function () {
- this.menuInitialized = false;
- // Set timeout for the menu to lazy build.
- setTimeout( this.lazyMenuCreation.bind( this ) );
- };
-
- /**
- * Update view
- */
- MenuSelectWidget.prototype.updateView = function () {
- var viewName = this.model.getCurrentView();
-
- if ( this.views[ viewName ] && this.currentView !== viewName ) {
- this.updateFooterVisibility( viewName );
-
- this.$element
- .data( 'view', viewName )
- .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
- .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
-
- this.currentView = viewName;
- this.scrollToTop();
+ }.bind( this ) );
+
+ // Switch to the correct view
+ this.updateView();
+};
+
+/* Initialize */
+
+OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
+
+/* Events */
+
+/* Methods */
+MenuSelectWidget.prototype.onModelSearchChange = function () {
+ this.updateView();
+};
+
+/**
+ * @inheritdoc
+ */
+MenuSelectWidget.prototype.toggle = function ( show ) {
+ this.lazyMenuCreation();
+ MenuSelectWidget.parent.prototype.toggle.call( this, show );
+ // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
+ this.setVerticalPosition( 'below' );
+};
+
+/**
+ * lazy creation of the menu
+ */
+MenuSelectWidget.prototype.lazyMenuCreation = function () {
+ var widget = this,
+ items = [],
+ viewGroupCount = {},
+ groups = this.model.getFilterGroups();
+
+ if ( this.menuInitialized ) {
+ return;
+ }
+
+ this.menuInitialized = true;
+
+ // Create shared popup for highlight buttons
+ this.highlightPopup = new HighlightPopupWidget( this.controller );
+ this.$overlay.append( this.highlightPopup.$element );
+
+ // Count groups per view
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( groups, function ( groupName, groupModel ) {
+ if ( !groupModel.isHidden() ) {
+ viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
+ viewGroupCount[ groupModel.getView() ]++;
}
-
- this.postProcessItems();
- this.clip();
- };
-
- /**
- * Go over the available footers and decide which should be visible
- * for this view
- *
- * @param {string} [currentView] Current view
- */
- MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
- currentView = currentView || this.model.getCurrentView();
-
- this.footers.forEach( function ( data ) {
- data.$element.toggle(
- // This footer should only be shown if it is configured
- // for all views or for this specific view
- !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
- );
- } );
- };
-
- /**
- * Post-process items after the visibility changed. Make sure
- * that we always have an item selected, and that the no-results
- * widget appears if the menu is empty.
- */
- MenuSelectWidget.prototype.postProcessItems = function () {
- var i,
- itemWasSelected = false,
- items = this.getItems();
-
- // If we are not already selecting an item, always make sure
- // that the top item is selected
- if ( !this.userSelecting ) {
- // Select the first item in the list
- for ( i = 0; i < items.length; i++ ) {
- if (
- !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
- items[ i ].isVisible()
- ) {
- itemWasSelected = true;
- this.selectItem( items[ i ] );
- break;
- }
+ } );
+
+ // eslint-disable-next-line no-jquery/no-each-util
+ $.each( groups, function ( groupName, groupModel ) {
+ var currentItems = [],
+ view = groupModel.getView();
+
+ if ( !groupModel.isHidden() ) {
+ if ( viewGroupCount[ view ] > 1 ) {
+ // Only add a section header if there is more than
+ // one group
+ currentItems.push(
+ // Group section
+ new FilterMenuSectionOptionWidget(
+ widget.controller,
+ groupModel,
+ {
+ $overlay: widget.$overlay
+ }
+ )
+ );
}
- if ( !itemWasSelected ) {
- this.selectItem( null );
- }
+ // Add items
+ widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
+ currentItems.push(
+ new FilterMenuOptionWidget(
+ widget.controller,
+ widget.model,
+ widget.model.getInvertModel(),
+ filterItem,
+ widget.highlightPopup,
+ {
+ $overlay: widget.$overlay
+ }
+ )
+ );
+ } );
+
+ // Cache the items per view, so we can switch between them
+ // without rebuilding the widgets each time
+ widget.views[ view ] = widget.views[ view ] || [];
+ widget.views[ view ] = widget.views[ view ].concat( currentItems );
+ items = items.concat( currentItems );
}
+ } );
+
+ this.addItems( items );
+ this.updateView();
+};
+
+/**
+ * Respond to model initialize event. Populate the menu from the model
+ */
+MenuSelectWidget.prototype.onModelInitialize = function () {
+ this.menuInitialized = false;
+ // Set timeout for the menu to lazy build.
+ setTimeout( this.lazyMenuCreation.bind( this ) );
+};
+
+/**
+ * Update view
+ */
+MenuSelectWidget.prototype.updateView = function () {
+ var viewName = this.model.getCurrentView();
+
+ if ( this.views[ viewName ] && this.currentView !== viewName ) {
+ this.updateFooterVisibility( viewName );
- this.noResults.toggle( !this.getItems().some( function ( item ) {
- return item.isVisible();
- } ) );
- };
-
- /**
- * Get the option widget that matches the model given
- *
- * @param {mw.rcfilters.dm.ItemModel} model Item model
- * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
- */
- MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
- this.lazyMenuCreation();
- return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
- return item.getName() === model.getName();
- } )[ 0 ];
- };
-
- /**
- * @inheritdoc
- */
- MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
- var nextItem,
- currentItem = this.findHighlightedItem() || this.findSelectedItem();
-
- // Call parent
- MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
-
- // We want to select the item on arrow movement
- // rather than just highlight it, like the menu
- // does by default
- if ( !this.isDisabled() && this.isVisible() ) {
- switch ( e.keyCode ) {
- case OO.ui.Keys.UP:
- case OO.ui.Keys.LEFT:
- // Get the next item
- nextItem = this.findRelativeSelectableItem( currentItem, -1 );
- break;
- case OO.ui.Keys.DOWN:
- case OO.ui.Keys.RIGHT:
- // Get the next item
- nextItem = this.findRelativeSelectableItem( currentItem, 1 );
- break;
+ this.$element
+ .data( 'view', viewName )
+ .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
+ .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
+
+ this.currentView = viewName;
+ this.scrollToTop();
+ }
+
+ this.postProcessItems();
+ this.clip();
+};
+
+/**
+ * Go over the available footers and decide which should be visible
+ * for this view
+ *
+ * @param {string} [currentView] Current view
+ */
+MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
+ currentView = currentView || this.model.getCurrentView();
+
+ this.footers.forEach( function ( data ) {
+ data.$element.toggle(
+ // This footer should only be shown if it is configured
+ // for all views or for this specific view
+ !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
+ );
+ } );
+};
+
+/**
+ * Post-process items after the visibility changed. Make sure
+ * that we always have an item selected, and that the no-results
+ * widget appears if the menu is empty.
+ */
+MenuSelectWidget.prototype.postProcessItems = function () {
+ var i,
+ itemWasSelected = false,
+ items = this.getItems();
+
+ // If we are not already selecting an item, always make sure
+ // that the top item is selected
+ if ( !this.userSelecting ) {
+ // Select the first item in the list
+ for ( i = 0; i < items.length; i++ ) {
+ if (
+ !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
+ items[ i ].isVisible()
+ ) {
+ itemWasSelected = true;
+ this.selectItem( items[ i ] );
+ break;
}
+ }
- nextItem = nextItem && nextItem.constructor.static.selectable ?
- nextItem : null;
-
- // Select the next item
- this.selectItem( nextItem );
+ if ( !itemWasSelected ) {
+ this.selectItem( null );
}
- };
-
- /**
- * Scroll to the top of the menu
- */
- MenuSelectWidget.prototype.scrollToTop = function () {
- this.$body.scrollTop( 0 );
- };
-
- /**
- * Set whether the user is currently selecting an item.
- * This is important when the user selects an item that is in between
- * different views, and makes sure we do not re-select a different
- * item (like the item on top) when this is happening.
- *
- * @param {boolean} isSelecting User is selecting
- */
- MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
- this.userSelecting = !!isSelecting;
- };
-
- module.exports = MenuSelectWidget;
-}() );
+ }
+
+ this.noResults.toggle( !this.getItems().some( function ( item ) {
+ return item.isVisible();
+ } ) );
+};
+
+/**
+ * Get the option widget that matches the model given
+ *
+ * @param {mw.rcfilters.dm.ItemModel} model Item model
+ * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
+ */
+MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
+ this.lazyMenuCreation();
+ return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
+ return item.getName() === model.getName();
+ } )[ 0 ];
+};
+
+/**
+ * @inheritdoc
+ */
+MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
+ var nextItem,
+ currentItem = this.findHighlightedItem() || this.findSelectedItem();
+
+ // Call parent
+ MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
+
+ // We want to select the item on arrow movement
+ // rather than just highlight it, like the menu
+ // does by default
+ if ( !this.isDisabled() && this.isVisible() ) {
+ switch ( e.keyCode ) {
+ case OO.ui.Keys.UP:
+ case OO.ui.Keys.LEFT:
+ // Get the next item
+ nextItem = this.findRelativeSelectableItem( currentItem, -1 );
+ break;
+ case OO.ui.Keys.DOWN:
+ case OO.ui.Keys.RIGHT:
+ // Get the next item
+ nextItem = this.findRelativeSelectableItem( currentItem, 1 );
+ break;
+ }
+
+ nextItem = nextItem && nextItem.constructor.static.selectable ?
+ nextItem : null;
+
+ // Select the next item
+ this.selectItem( nextItem );
+ }
+};
+
+/**
+ * Scroll to the top of the menu
+ */
+MenuSelectWidget.prototype.scrollToTop = function () {
+ this.$body.scrollTop( 0 );
+};
+
+/**
+ * Set whether the user is currently selecting an item.
+ * This is important when the user selects an item that is in between
+ * different views, and makes sure we do not re-select a different
+ * item (like the item on top) when this is happening.
+ *
+ * @param {boolean} isSelecting User is selecting
+ */
+MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
+ this.userSelecting = !!isSelecting;
+};
+
+module.exports = MenuSelectWidget;
-( function () {
- /**
- * Top section (between page title and filters) on Special:Recentchanges
- *
- * @class mw.rcfilters.ui.RcTopSectionWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
- * @param {jQuery} $topLinks Content of the community-defined links
- * @param {Object} [config] Configuration object
- */
- var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
- savedLinksListWidget, $topLinks, config
- ) {
- var toplinksTitle,
- topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
- topLinksCookie = mw.cookie.get( topLinksCookieName ),
- topLinksCookieValue = topLinksCookie || 'collapsed',
- widget = this;
+/**
+ * Top section (between page title and filters) on Special:Recentchanges
+ *
+ * @class mw.rcfilters.ui.RcTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {jQuery} $topLinks Content of the community-defined links
+ * @param {Object} [config] Configuration object
+ */
+var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
+ savedLinksListWidget, $topLinks, config
+) {
+ var toplinksTitle,
+ topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
+ topLinksCookie = mw.cookie.get( topLinksCookieName ),
+ topLinksCookieValue = topLinksCookie || 'collapsed',
+ widget = this;
- config = config || {};
+ config = config || {};
- // Parent
- RcTopSectionWidget.parent.call( this, config );
+ // Parent
+ RcTopSectionWidget.parent.call( this, config );
- this.$topLinks = $topLinks;
+ this.$topLinks = $topLinks;
- toplinksTitle = new OO.ui.ButtonWidget( {
- framed: false,
- indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
- flags: [ 'progressive' ],
- label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
- } );
+ toplinksTitle = new OO.ui.ButtonWidget( {
+ framed: false,
+ indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
+ flags: [ 'progressive' ],
+ label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
+ } );
- this.$topLinks
- .makeCollapsible( {
- collapsed: topLinksCookieValue === 'collapsed',
- $customTogglers: toplinksTitle.$element
- } )
- .on( 'beforeExpand.mw-collapsible', function () {
- mw.cookie.set( topLinksCookieName, 'expanded' );
- toplinksTitle.setIndicator( 'up' );
- widget.switchTopLinks( 'expanded' );
- } )
- .on( 'beforeCollapse.mw-collapsible', function () {
- mw.cookie.set( topLinksCookieName, 'collapsed' );
- toplinksTitle.setIndicator( 'down' );
- widget.switchTopLinks( 'collapsed' );
- } );
+ this.$topLinks
+ .makeCollapsible( {
+ collapsed: topLinksCookieValue === 'collapsed',
+ $customTogglers: toplinksTitle.$element
+ } )
+ .on( 'beforeExpand.mw-collapsible', function () {
+ mw.cookie.set( topLinksCookieName, 'expanded' );
+ toplinksTitle.setIndicator( 'up' );
+ widget.switchTopLinks( 'expanded' );
+ } )
+ .on( 'beforeCollapse.mw-collapsible', function () {
+ mw.cookie.set( topLinksCookieName, 'collapsed' );
+ toplinksTitle.setIndicator( 'down' );
+ widget.switchTopLinks( 'collapsed' );
+ } );
- this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
- .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
+ this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
+ .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
- // Create two positions for the toplinks to toggle between
- // in the table (first cell) or up above it
- this.$top = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
- this.$tableTopLinks = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
+ // Create two positions for the toplinks to toggle between
+ // in the table (first cell) or up above it
+ this.$top = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
+ this.$tableTopLinks = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- this.$tableTopLinks,
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ this.$tableTopLinks,
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table-placeholder' )
+ .addClass( 'mw-rcfilters-ui-cell' ),
+ !mw.user.isAnon() ?
$( '<div>' )
- .addClass( 'mw-rcfilters-ui-table-placeholder' )
- .addClass( 'mw-rcfilters-ui-cell' ),
- !mw.user.isAnon() ?
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
- .append( savedLinksListWidget.$element ) :
- null
- )
- )
- );
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
+ .append( savedLinksListWidget.$element ) :
+ null
+ )
+ )
+ );
- // Hack: For jumpiness reasons, this should be a sibling of -head
- $( '.rcfilters-head' ).before( this.$top );
+ // Hack: For jumpiness reasons, this should be a sibling of -head
+ $( '.rcfilters-head' ).before( this.$top );
- // Initialize top links position
- widget.switchTopLinks( topLinksCookieValue );
- };
+ // Initialize top links position
+ widget.switchTopLinks( topLinksCookieValue );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
- /**
- * Switch the top links widget from inside the table (when collapsed)
- * to the 'top' (when open)
- *
- * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
- */
- RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
- state = state || 'expanded';
+/**
+ * Switch the top links widget from inside the table (when collapsed)
+ * to the 'top' (when open)
+ *
+ * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
+ */
+RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
+ state = state || 'expanded';
- if ( state === 'expanded' ) {
- this.$top.append( this.$topLinks );
- } else {
- this.$tableTopLinks.append( this.$topLinks );
- }
- this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
- };
+ if ( state === 'expanded' ) {
+ this.$top.append( this.$topLinks );
+ } else {
+ this.$tableTopLinks.append( this.$topLinks );
+ }
+ this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
+};
- module.exports = RcTopSectionWidget;
-}() );
+module.exports = RcTopSectionWidget;
-( function () {
- /**
- * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
- *
- * @class mw.rcfilters.ui.RclTargetPageWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FilterItem} targetPageModel
- * @param {Object} [config] Configuration object
- */
- var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
- controller, targetPageModel, config
- ) {
- config = config || {};
+/**
+ * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
+ *
+ * @class mw.rcfilters.ui.RclTargetPageWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} targetPageModel
+ * @param {Object} [config] Configuration object
+ */
+var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
+ controller, targetPageModel, config
+) {
+ config = config || {};
- // Parent
- RclTargetPageWidget.parent.call( this, config );
+ // Parent
+ RclTargetPageWidget.parent.call( this, config );
- this.controller = controller;
- this.model = targetPageModel;
+ this.controller = controller;
+ this.model = targetPageModel;
- this.titleSearch = new mw.widgets.TitleInputWidget( {
- validate: false,
- placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
- showImages: true,
- showDescriptions: true,
- addQueryInput: false
- } );
+ this.titleSearch = new mw.widgets.TitleInputWidget( {
+ validate: false,
+ placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
+ showImages: true,
+ showDescriptions: true,
+ addQueryInput: false
+ } );
- // Events
- this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+ // Events
+ this.model.connect( this, { update: 'updateUiBasedOnModel' } );
- this.titleSearch.$input.on( {
- blur: this.onLookupInputBlur.bind( this )
- } );
+ this.titleSearch.$input.on( {
+ blur: this.onLookupInputBlur.bind( this )
+ } );
- this.titleSearch.lookupMenu.connect( this, {
- choose: 'onLookupMenuItemChoose'
- } );
+ this.titleSearch.lookupMenu.connect( this, {
+ choose: 'onLookupMenuItemChoose'
+ } );
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
- .append( this.titleSearch.$element );
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
+ .append( this.titleSearch.$element );
- this.updateUiBasedOnModel();
- };
+ this.updateUiBasedOnModel();
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
+OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
- /* Methods */
+/* Methods */
- /**
- * Respond to the user choosing a title
- */
- RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
- this.titleSearch.$input.trigger( 'blur' );
- };
+/**
+ * Respond to the user choosing a title
+ */
+RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
+ this.titleSearch.$input.trigger( 'blur' );
+};
- /**
- * Respond to titleSearch $input blur
- */
- RclTargetPageWidget.prototype.onLookupInputBlur = function () {
- this.controller.setTargetPage( this.titleSearch.getQueryValue() );
- };
+/**
+ * Respond to titleSearch $input blur
+ */
+RclTargetPageWidget.prototype.onLookupInputBlur = function () {
+ this.controller.setTargetPage( this.titleSearch.getQueryValue() );
+};
- /**
- * Respond to the model being updated
- */
- RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
- var title = mw.Title.newFromText( this.model.getValue() ),
- text = title ? title.toText() : this.model.getValue();
- this.titleSearch.setValue( text );
- this.titleSearch.setTitle( text );
- };
+/**
+ * Respond to the model being updated
+ */
+RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
+ var title = mw.Title.newFromText( this.model.getValue() ),
+ text = title ? title.toText() : this.model.getValue();
+ this.titleSearch.setValue( text );
+ this.titleSearch.setTitle( text );
+};
- module.exports = RclTargetPageWidget;
-}() );
+module.exports = RclTargetPageWidget;
-( function () {
- /**
- * Widget to select to view changes that link TO or FROM the target page
- * on Special:RecentChangesLinked (AKA Related Changes)
- *
- * @class mw.rcfilters.ui.RclToOrFromWidget
- * @extends OO.ui.DropdownWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
- * @param {Object} [config] Configuration object
- */
- var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
- controller, showLinkedToModel, config
- ) {
- config = config || {};
+/**
+ * Widget to select to view changes that link TO or FROM the target page
+ * on Special:RecentChangesLinked (AKA Related Changes)
+ *
+ * @class mw.rcfilters.ui.RclToOrFromWidget
+ * @extends OO.ui.DropdownWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
+ * @param {Object} [config] Configuration object
+ */
+var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
+ controller, showLinkedToModel, config
+) {
+ config = config || {};
- this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
- data: 'from', // default (showlinkedto=0)
- label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
- } );
- this.showLinkedTo = new OO.ui.MenuOptionWidget( {
- data: 'to', // showlinkedto=1
- label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
- } );
+ this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
+ data: 'from', // default (showlinkedto=0)
+ label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
+ } );
+ this.showLinkedTo = new OO.ui.MenuOptionWidget( {
+ data: 'to', // showlinkedto=1
+ label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
+ } );
- // Parent
- RclToOrFromWidget.parent.call( this, $.extend( {
- classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
- menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
- }, config ) );
+ // Parent
+ RclToOrFromWidget.parent.call( this, $.extend( {
+ classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
+ menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
+ }, config ) );
- this.controller = controller;
- this.model = showLinkedToModel;
+ this.controller = controller;
+ this.model = showLinkedToModel;
- this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
- this.model.connect( this, { update: 'onModelUpdate' } );
+ this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
+ this.model.connect( this, { update: 'onModelUpdate' } );
- // force an initial update of the component based on the state
- this.onModelUpdate();
- };
+ // force an initial update of the component based on the state
+ this.onModelUpdate();
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
+OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
- /* Methods */
+/* Methods */
- /**
- * Respond to the user choosing an item in the menu
- *
- * @param {OO.ui.MenuOptionWidget} chosenItem
- */
- RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
- this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
- };
+/**
+ * Respond to the user choosing an item in the menu
+ *
+ * @param {OO.ui.MenuOptionWidget} chosenItem
+ */
+RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
+ this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
+};
- /**
- * Respond to model update
- */
- RclToOrFromWidget.prototype.onModelUpdate = function () {
- this.getMenu().selectItem(
- this.model.isSelected() ?
- this.showLinkedTo :
- this.showLinkedFrom
- );
- this.setLabel( mw.msg(
- this.model.isSelected() ?
- 'rcfilters-filter-showlinkedto-label' :
- 'rcfilters-filter-showlinkedfrom-label'
- ) );
- };
+/**
+ * Respond to model update
+ */
+RclToOrFromWidget.prototype.onModelUpdate = function () {
+ this.getMenu().selectItem(
+ this.model.isSelected() ?
+ this.showLinkedTo :
+ this.showLinkedFrom
+ );
+ this.setLabel( mw.msg(
+ this.model.isSelected() ?
+ 'rcfilters-filter-showlinkedto-label' :
+ 'rcfilters-filter-showlinkedfrom-label'
+ ) );
+};
- module.exports = RclToOrFromWidget;
-}() );
+module.exports = RclToOrFromWidget;
-( function () {
- var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
- RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
- RclTopSectionWidget;
+var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
+ RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
+ RclTopSectionWidget;
- /**
- * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
- *
- * @class mw.rcfilters.ui.RclTopSectionWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
- * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
- * @param {Object} [config] Configuration object
- */
- RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
- savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
- ) {
- var toOrFromWidget,
- targetPage;
- config = config || {};
+/**
+ * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
+ *
+ * @class mw.rcfilters.ui.RclTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
+ * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
+ * @param {Object} [config] Configuration object
+ */
+RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
+ savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
+) {
+ var toOrFromWidget,
+ targetPage;
+ config = config || {};
- // Parent
- RclTopSectionWidget.parent.call( this, config );
+ // Parent
+ RclTopSectionWidget.parent.call( this, config );
- this.controller = controller;
+ this.controller = controller;
- toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
- targetPage = new RclTargetPageWidget( controller, targetPageModel );
+ toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
+ targetPage = new RclTargetPageWidget( controller, targetPageModel );
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .append( toOrFromWidget.$element )
+ ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .append( targetPage.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table-placeholder' )
+ .addClass( 'mw-rcfilters-ui-cell' ),
+ !mw.user.isAnon() ?
$( '<div>' )
.addClass( 'mw-rcfilters-ui-cell' )
- .append( toOrFromWidget.$element )
- ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .append( targetPage.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table-placeholder' )
- .addClass( 'mw-rcfilters-ui-cell' ),
- !mw.user.isAnon() ?
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
- .append( savedLinksListWidget.$element ) :
- null
- )
- )
- );
- };
+ .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
+ .append( savedLinksListWidget.$element ) :
+ null
+ )
+ )
+ );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
- module.exports = RclTopSectionWidget;
-}() );
+module.exports = RclTopSectionWidget;
-( function () {
- /**
- * Save filters widget. This widget is displayed in the tag area
- * and allows the user to save the current state of the system
- * as a new saved filter query they can later load or set as
- * default.
- *
- * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
- * @extends OO.ui.PopupButtonWidget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
- * @param {Object} [config] Configuration object
- */
- var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
- var layout,
- checkBoxLayout,
- $popupContent = $( '<div>' );
-
- config = config || {};
-
- this.controller = controller;
- this.model = model;
-
- // Parent
- SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
- framed: false,
- icon: 'bookmark',
- title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
- popup: {
- classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
- padded: true,
- head: true,
- label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
- $content: $popupContent
- }
- }, config ) );
- // // HACK: Add an icon to the popup head label
- this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
-
- this.input = new OO.ui.TextInputWidget( {
- placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
- } );
- layout = new OO.ui.FieldLayout( this.input, {
- label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
- align: 'top'
- } );
-
- this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
- checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
- label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
- align: 'inline'
- } );
-
- this.applyButton = new OO.ui.ButtonWidget( {
- label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
- classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
- flags: [ 'primary', 'progressive' ]
- } );
- this.cancelButton = new OO.ui.ButtonWidget( {
- label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
- classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
- } );
-
- $popupContent
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
- .append( layout.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
- .append( checkBoxLayout.$element ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
- .append(
- this.cancelButton.$element,
- this.applyButton.$element
- )
- );
-
- // Events
- this.popup.connect( this, {
- ready: 'onPopupReady'
- } );
- this.input.connect( this, {
- change: 'onInputChange',
- enter: 'onInputEnter'
- } );
- this.input.$input.on( {
- keyup: this.onInputKeyup.bind( this )
- } );
- this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
- this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
- this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
-
- // Initialize
- this.applyButton.setDisabled( !this.input.getValue() );
- this.$element
- .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
- };
-
- /* Initialization */
- OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
-
- /**
- * Respond to input enter event
- */
- SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
- this.apply();
- };
-
- /**
- * Respond to input change event
- *
- * @param {string} value Input value
- */
- SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
- value = value.trim();
-
- this.applyButton.setDisabled( !value );
- };
-
- /**
- * Respond to input keyup event, this is the way to intercept 'escape' key
- *
- * @param {jQuery.Event} e Event data
- * @return {boolean} false
- */
- SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
- if ( e.which === OO.ui.Keys.ESCAPE ) {
- this.popup.toggle( false );
- return false;
+/**
+ * Save filters widget. This widget is displayed in the tag area
+ * and allows the user to save the current state of the system
+ * as a new saved filter query they can later load or set as
+ * default.
+ *
+ * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
+ * @extends OO.ui.PopupButtonWidget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+ * @param {Object} [config] Configuration object
+ */
+var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
+ var layout,
+ checkBoxLayout,
+ $popupContent = $( '<div>' );
+
+ config = config || {};
+
+ this.controller = controller;
+ this.model = model;
+
+ // Parent
+ SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
+ framed: false,
+ icon: 'bookmark',
+ title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+ popup: {
+ classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
+ padded: true,
+ head: true,
+ label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+ $content: $popupContent
}
- };
-
- /**
- * Respond to popup ready event
- */
- SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
- this.input.focus();
- };
-
- /**
- * Respond to "set as default" checkbox change
- * @param {boolean} checked State of the checkbox
- */
- SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
- var messageKey = checked ?
- 'rcfilters-savedqueries-apply-and-setdefault-label' :
- 'rcfilters-savedqueries-apply-label';
-
- this.applyButton
- .setIcon( checked ? 'pushPin' : null )
- .setLabel( mw.msg( messageKey ) );
- };
-
- /**
- * Respond to cancel button click event
- */
- SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
+ }, config ) );
+ // // HACK: Add an icon to the popup head label
+ this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
+
+ this.input = new OO.ui.TextInputWidget( {
+ placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
+ } );
+ layout = new OO.ui.FieldLayout( this.input, {
+ label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
+ align: 'top'
+ } );
+
+ this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
+ checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
+ label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
+ align: 'inline'
+ } );
+
+ this.applyButton = new OO.ui.ButtonWidget( {
+ label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
+ classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
+ flags: [ 'primary', 'progressive' ]
+ } );
+ this.cancelButton = new OO.ui.ButtonWidget( {
+ label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
+ classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
+ } );
+
+ $popupContent
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
+ .append( layout.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
+ .append( checkBoxLayout.$element ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
+ .append(
+ this.cancelButton.$element,
+ this.applyButton.$element
+ )
+ );
+
+ // Events
+ this.popup.connect( this, {
+ ready: 'onPopupReady'
+ } );
+ this.input.connect( this, {
+ change: 'onInputChange',
+ enter: 'onInputEnter'
+ } );
+ this.input.$input.on( {
+ keyup: this.onInputKeyup.bind( this )
+ } );
+ this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
+ this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
+ this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
+
+ // Initialize
+ this.applyButton.setDisabled( !this.input.getValue() );
+ this.$element
+ .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
+};
+
+/* Initialization */
+OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
+
+/**
+ * Respond to input enter event
+ */
+SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
+ this.apply();
+};
+
+/**
+ * Respond to input change event
+ *
+ * @param {string} value Input value
+ */
+SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
+ value = value.trim();
+
+ this.applyButton.setDisabled( !value );
+};
+
+/**
+ * Respond to input keyup event, this is the way to intercept 'escape' key
+ *
+ * @param {jQuery.Event} e Event data
+ * @return {boolean} false
+ */
+SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
+ if ( e.which === OO.ui.Keys.ESCAPE ) {
this.popup.toggle( false );
- };
-
- /**
- * Respond to apply button click event
- */
- SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
- this.apply();
- };
-
- /**
- * Apply and add the new quick link
- */
- SaveFiltersPopupButtonWidget.prototype.apply = function () {
- var label = this.input.getValue().trim();
-
- // This condition is more for sanity-check, since the
- // apply button should be disabled if the label is empty
- if ( label ) {
- this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
- this.input.setValue( '' );
- this.setAsDefaultCheckbox.setSelected( false );
- this.popup.toggle( false );
-
- this.emit( 'saveCurrent' );
- }
- };
+ return false;
+ }
+};
+
+/**
+ * Respond to popup ready event
+ */
+SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
+ this.input.focus();
+};
+
+/**
+ * Respond to "set as default" checkbox change
+ * @param {boolean} checked State of the checkbox
+ */
+SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
+ var messageKey = checked ?
+ 'rcfilters-savedqueries-apply-and-setdefault-label' :
+ 'rcfilters-savedqueries-apply-label';
+
+ this.applyButton
+ .setIcon( checked ? 'pushPin' : null )
+ .setLabel( mw.msg( messageKey ) );
+};
+
+/**
+ * Respond to cancel button click event
+ */
+SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
+ this.popup.toggle( false );
+};
+
+/**
+ * Respond to apply button click event
+ */
+SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
+ this.apply();
+};
+
+/**
+ * Apply and add the new quick link
+ */
+SaveFiltersPopupButtonWidget.prototype.apply = function () {
+ var label = this.input.getValue().trim();
+
+ // This condition is more for sanity-check, since the
+ // apply button should be disabled if the label is empty
+ if ( label ) {
+ this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
+ this.input.setValue( '' );
+ this.setAsDefaultCheckbox.setSelected( false );
+ this.popup.toggle( false );
+
+ this.emit( 'saveCurrent' );
+ }
+};
- module.exports = SaveFiltersPopupButtonWidget;
-}() );
+module.exports = SaveFiltersPopupButtonWidget;
-( function () {
- /**
- * Quick links menu option widget
- *
- * @class mw.rcfilters.ui.SavedLinksListItemWidget
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.TitledElement
- *
- * @constructor
- * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
- * @param {Object} [config] Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
- config = config || {};
-
- this.model = model;
-
- // Parent
- SavedLinksListItemWidget.parent.call( this, $.extend( {
- data: this.model.getID()
- }, config ) );
-
- // Mixin constructors
- OO.ui.mixin.LabelElement.call( this, $.extend( {
- label: this.model.getLabel()
- }, config ) );
- OO.ui.mixin.IconElement.call( this, $.extend( {
- icon: ''
- }, config ) );
- OO.ui.mixin.TitledElement.call( this, $.extend( {
- title: this.model.getLabel()
- }, config ) );
-
- this.edit = false;
- this.$overlay = config.$overlay || this.$element;
-
- this.popupButton = new OO.ui.ButtonWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
- icon: 'ellipsis',
- framed: false
- } );
- this.menu = new OO.ui.MenuSelectWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
- widget: this.popupButton,
- width: 200,
- horizontalPosition: 'end',
- $floatableContainer: this.popupButton.$element,
- items: [
- new OO.ui.MenuOptionWidget( {
- data: 'edit',
- icon: 'edit',
- label: mw.msg( 'rcfilters-savedqueries-rename' )
- } ),
- new OO.ui.MenuOptionWidget( {
- data: 'delete',
- icon: 'trash',
- label: mw.msg( 'rcfilters-savedqueries-remove' )
- } ),
- new OO.ui.MenuOptionWidget( {
- data: 'default',
- icon: 'pushPin',
- label: mw.msg( 'rcfilters-savedqueries-setdefault' )
- } )
- ]
- } );
-
- this.editInput = new OO.ui.TextInputWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
- } );
- this.saveButton = new OO.ui.ButtonWidget( {
- icon: 'check',
- flags: [ 'primary', 'progressive' ]
- } );
+/**
+ * Quick links menu option widget
+ *
+ * @class mw.rcfilters.ui.SavedLinksListItemWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
+ config = config || {};
+
+ this.model = model;
+
+ // Parent
+ SavedLinksListItemWidget.parent.call( this, $.extend( {
+ data: this.model.getID()
+ }, config ) );
+
+ // Mixin constructors
+ OO.ui.mixin.LabelElement.call( this, $.extend( {
+ label: this.model.getLabel()
+ }, config ) );
+ OO.ui.mixin.IconElement.call( this, $.extend( {
+ icon: ''
+ }, config ) );
+ OO.ui.mixin.TitledElement.call( this, $.extend( {
+ title: this.model.getLabel()
+ }, config ) );
+
+ this.edit = false;
+ this.$overlay = config.$overlay || this.$element;
+
+ this.popupButton = new OO.ui.ButtonWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
+ icon: 'ellipsis',
+ framed: false
+ } );
+ this.menu = new OO.ui.MenuSelectWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
+ widget: this.popupButton,
+ width: 200,
+ horizontalPosition: 'end',
+ $floatableContainer: this.popupButton.$element,
+ items: [
+ new OO.ui.MenuOptionWidget( {
+ data: 'edit',
+ icon: 'edit',
+ label: mw.msg( 'rcfilters-savedqueries-rename' )
+ } ),
+ new OO.ui.MenuOptionWidget( {
+ data: 'delete',
+ icon: 'trash',
+ label: mw.msg( 'rcfilters-savedqueries-remove' )
+ } ),
+ new OO.ui.MenuOptionWidget( {
+ data: 'default',
+ icon: 'pushPin',
+ label: mw.msg( 'rcfilters-savedqueries-setdefault' )
+ } )
+ ]
+ } );
+
+ this.editInput = new OO.ui.TextInputWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
+ } );
+ this.saveButton = new OO.ui.ButtonWidget( {
+ icon: 'check',
+ flags: [ 'primary', 'progressive' ]
+ } );
+ this.toggleEdit( false );
+
+ // Events
+ this.model.connect( this, { update: 'onModelUpdate' } );
+ this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
+ this.menu.connect( this, {
+ choose: 'onMenuChoose'
+ } );
+ this.saveButton.connect( this, { click: 'save' } );
+ this.editInput.connect( this, {
+ change: 'onInputChange',
+ enter: 'save'
+ } );
+ this.editInput.$input.on( {
+ blur: this.onInputBlur.bind( this ),
+ keyup: this.onInputKeyup.bind( this )
+ } );
+ this.$element.on( { click: this.onClick.bind( this ) } );
+ this.$label.on( { click: this.onClick.bind( this ) } );
+ this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
+ // Prevent propagation on mousedown for the save button
+ // so the menu doesn't close
+ this.saveButton.$element.on( { mousedown: function () {
+ return false;
+ } } );
+
+ // Initialize
+ this.toggleDefault( !!this.model.isDefault() );
+ this.$overlay.append( this.menu.$element );
+ this.$element
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
+ .append(
+ this.$label
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
+ this.editInput.$element,
+ this.saveButton.$element
+ ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
+ .append( this.$icon ),
+ this.popupButton.$element
+ .addClass( 'mw-rcfilters-ui-cell' )
+ )
+ )
+ );
+};
+
+/* Initialization */
+OO.inheritClass( SavedLinksListItemWidget, OO.ui.Widget );
+OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
+
+/* Events */
+
+/**
+ * @event delete
+ *
+ * The delete option was selected for this item
+ */
+
+/**
+ * @event default
+ * @param {boolean} default Item is default
+ *
+ * The 'make default' option was selected for this item
+ */
+
+/**
+ * @event edit
+ * @param {string} newLabel New label for the query
+ *
+ * The label has been edited
+ */
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+SavedLinksListItemWidget.prototype.onModelUpdate = function () {
+ this.setLabel( this.model.getLabel() );
+ this.toggleDefault( this.model.isDefault() );
+};
+
+/**
+ * Respond to click on the element or label
+ *
+ * @fires click
+ */
+SavedLinksListItemWidget.prototype.onClick = function () {
+ if ( !this.editing ) {
+ this.emit( 'click' );
+ }
+};
+
+/**
+ * Respond to click on the 'default' icon. Open the submenu where the
+ * default state can be changed.
+ *
+ * @return {boolean} false
+ */
+SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
+ this.menu.toggle();
+ return false;
+};
+
+/**
+ * Respond to popup button click event
+ */
+SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
+ this.menu.toggle();
+};
+
+/**
+ * Respond to menu choose event
+ *
+ * @param {OO.ui.MenuOptionWidget} item Chosen item
+ * @fires delete
+ * @fires default
+ */
+SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
+ var action = item.getData();
+
+ if ( action === 'edit' ) {
+ this.toggleEdit( true );
+ } else if ( action === 'delete' ) {
+ this.emit( 'delete' );
+ } else if ( action === 'default' ) {
+ this.emit( 'default', !this.default );
+ }
+ // Reset selected
+ this.menu.selectItem( null );
+ // Close the menu
+ this.menu.toggle( false );
+};
+
+/**
+ * Respond to input keyup event, this is the way to intercept 'escape' key
+ *
+ * @param {jQuery.Event} e Event data
+ * @return {boolean} false
+ */
+SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
+ if ( e.which === OO.ui.Keys.ESCAPE ) {
+ // Return the input to the original label
+ this.editInput.setValue( this.getLabel() );
this.toggleEdit( false );
-
- // Events
- this.model.connect( this, { update: 'onModelUpdate' } );
- this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
- this.menu.connect( this, {
- choose: 'onMenuChoose'
- } );
- this.saveButton.connect( this, { click: 'save' } );
- this.editInput.connect( this, {
- change: 'onInputChange',
- enter: 'save'
- } );
- this.editInput.$input.on( {
- blur: this.onInputBlur.bind( this ),
- keyup: this.onInputKeyup.bind( this )
- } );
- this.$element.on( { click: this.onClick.bind( this ) } );
- this.$label.on( { click: this.onClick.bind( this ) } );
- this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
- // Prevent propagation on mousedown for the save button
- // so the menu doesn't close
- this.saveButton.$element.on( { mousedown: function () {
- return false;
- } } );
-
- // Initialize
- this.toggleDefault( !!this.model.isDefault() );
- this.$overlay.append( this.menu.$element );
- this.$element
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
- .append(
- this.$label
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
- this.editInput.$element,
- this.saveButton.$element
- ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
- .append( this.$icon ),
- this.popupButton.$element
- .addClass( 'mw-rcfilters-ui-cell' )
- )
- )
- );
- };
-
- /* Initialization */
- OO.inheritClass( SavedLinksListItemWidget, OO.ui.Widget );
- OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
- OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.IconElement );
- OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
-
- /* Events */
-
- /**
- * @event delete
- *
- * The delete option was selected for this item
- */
-
- /**
- * @event default
- * @param {boolean} default Item is default
- *
- * The 'make default' option was selected for this item
- */
-
- /**
- * @event edit
- * @param {string} newLabel New label for the query
- *
- * The label has been edited
- */
-
- /* Methods */
-
- /**
- * Respond to model update event
- */
- SavedLinksListItemWidget.prototype.onModelUpdate = function () {
- this.setLabel( this.model.getLabel() );
- this.toggleDefault( this.model.isDefault() );
- };
-
- /**
- * Respond to click on the element or label
- *
- * @fires click
- */
- SavedLinksListItemWidget.prototype.onClick = function () {
- if ( !this.editing ) {
- this.emit( 'click' );
- }
- };
-
- /**
- * Respond to click on the 'default' icon. Open the submenu where the
- * default state can be changed.
- *
- * @return {boolean} false
- */
- SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
- this.menu.toggle();
return false;
- };
-
- /**
- * Respond to popup button click event
- */
- SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
- this.menu.toggle();
- };
-
- /**
- * Respond to menu choose event
- *
- * @param {OO.ui.MenuOptionWidget} item Chosen item
- * @fires delete
- * @fires default
- */
- SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
- var action = item.getData();
-
- if ( action === 'edit' ) {
- this.toggleEdit( true );
- } else if ( action === 'delete' ) {
- this.emit( 'delete' );
- } else if ( action === 'default' ) {
- this.emit( 'default', !this.default );
- }
- // Reset selected
- this.menu.selectItem( null );
- // Close the menu
- this.menu.toggle( false );
- };
-
- /**
- * Respond to input keyup event, this is the way to intercept 'escape' key
- *
- * @param {jQuery.Event} e Event data
- * @return {boolean} false
- */
- SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
- if ( e.which === OO.ui.Keys.ESCAPE ) {
- // Return the input to the original label
- this.editInput.setValue( this.getLabel() );
- this.toggleEdit( false );
- return false;
- }
- };
-
- /**
- * Respond to blur event on the input
- */
- SavedLinksListItemWidget.prototype.onInputBlur = function () {
- this.save();
-
- // Whether the save succeeded or not, the input-blur event
- // means we need to cancel editing mode
+ }
+};
+
+/**
+ * Respond to blur event on the input
+ */
+SavedLinksListItemWidget.prototype.onInputBlur = function () {
+ this.save();
+
+ // Whether the save succeeded or not, the input-blur event
+ // means we need to cancel editing mode
+ this.toggleEdit( false );
+};
+
+/**
+ * Respond to input change event
+ *
+ * @param {string} value Input value
+ */
+SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
+ value = value.trim();
+
+ this.saveButton.setDisabled( !value );
+};
+
+/**
+ * Save the name of the query
+ *
+ * @param {string} [value] The value to save
+ * @fires edit
+ */
+SavedLinksListItemWidget.prototype.save = function () {
+ var value = this.editInput.getValue().trim();
+
+ if ( value ) {
+ this.emit( 'edit', value );
this.toggleEdit( false );
- };
-
- /**
- * Respond to input change event
- *
- * @param {string} value Input value
- */
- SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
- value = value.trim();
-
- this.saveButton.setDisabled( !value );
- };
-
- /**
- * Save the name of the query
- *
- * @param {string} [value] The value to save
- * @fires edit
- */
- SavedLinksListItemWidget.prototype.save = function () {
- var value = this.editInput.getValue().trim();
-
- if ( value ) {
- this.emit( 'edit', value );
- this.toggleEdit( false );
- }
- };
-
- /**
- * Toggle edit mode on this widget
- *
- * @param {boolean} isEdit Widget is in edit mode
- */
- SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
- isEdit = isEdit === undefined ? !this.editing : isEdit;
-
- if ( this.editing !== isEdit ) {
- this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
- this.editInput.setValue( this.getLabel() );
-
- this.editInput.toggle( isEdit );
- this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
- this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
- this.popupButton.toggle( !isEdit );
- this.saveButton.toggle( isEdit );
-
- if ( isEdit ) {
- this.editInput.$input.trigger( 'focus' );
- }
- this.editing = isEdit;
- }
- };
-
- /**
- * Toggle default this widget
- *
- * @param {boolean} isDefault This item is default
- */
- SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
- isDefault = isDefault === undefined ? !this.default : isDefault;
-
- if ( this.default !== isDefault ) {
- this.default = isDefault;
- this.setIcon( this.default ? 'pushPin' : '' );
- this.menu.findItemFromData( 'default' ).setLabel(
- this.default ?
- mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
- mw.msg( 'rcfilters-savedqueries-setdefault' )
- );
+ }
+};
+
+/**
+ * Toggle edit mode on this widget
+ *
+ * @param {boolean} isEdit Widget is in edit mode
+ */
+SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
+ isEdit = isEdit === undefined ? !this.editing : isEdit;
+
+ if ( this.editing !== isEdit ) {
+ this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
+ this.editInput.setValue( this.getLabel() );
+
+ this.editInput.toggle( isEdit );
+ this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
+ this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
+ this.popupButton.toggle( !isEdit );
+ this.saveButton.toggle( isEdit );
+
+ if ( isEdit ) {
+ this.editInput.$input.trigger( 'focus' );
}
- };
-
- /**
- * Get item ID
- *
- * @return {string} Query identifier
- */
- SavedLinksListItemWidget.prototype.getID = function () {
- return this.model.getID();
- };
-
- module.exports = SavedLinksListItemWidget;
-
-}() );
+ this.editing = isEdit;
+ }
+};
+
+/**
+ * Toggle default this widget
+ *
+ * @param {boolean} isDefault This item is default
+ */
+SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
+ isDefault = isDefault === undefined ? !this.default : isDefault;
+
+ if ( this.default !== isDefault ) {
+ this.default = isDefault;
+ this.setIcon( this.default ? 'pushPin' : '' );
+ this.menu.findItemFromData( 'default' ).setLabel(
+ this.default ?
+ mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
+ mw.msg( 'rcfilters-savedqueries-setdefault' )
+ );
+ }
+};
+
+/**
+ * Get item ID
+ *
+ * @return {string} Query identifier
+ */
+SavedLinksListItemWidget.prototype.getID = function () {
+ return this.model.getID();
+};
+
+module.exports = SavedLinksListItemWidget;
-( function () {
- var GroupWidget = require( './GroupWidget.js' ),
- SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
- SavedLinksListWidget;
-
- /**
- * Quick links widget
- *
- * @class mw.rcfilters.ui.SavedLinksListWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
- * @param {Object} [config] Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
- var $labelNoEntries = $( '<div>' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
- .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
- .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
- );
-
- config = config || {};
-
- // Parent
- SavedLinksListWidget.parent.call( this, config );
-
- this.controller = controller;
- this.model = model;
- this.$overlay = config.$overlay || this.$element;
-
- this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
- label: $labelNoEntries,
- icon: 'bookmark'
- } );
-
- this.menu = new GroupWidget( {
- events: {
- click: 'menuItemClick',
- delete: 'menuItemDelete',
- default: 'menuItemDefault',
- edit: 'menuItemEdit'
- },
- classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
- items: [ this.placeholderItem ]
- } );
- this.button = new OO.ui.PopupButtonWidget( {
- classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
- label: mw.msg( 'rcfilters-quickfilters' ),
- icon: 'bookmark',
- indicator: 'down',
- $overlay: this.$overlay,
- popup: {
- width: 300,
- anchor: false,
- align: 'backwards',
- $autoCloseIgnore: this.$overlay,
- $content: this.menu.$element
- }
- } );
-
- // Events
- this.model.connect( this, {
- add: 'onModelAddItem',
- remove: 'onModelRemoveItem'
- } );
- this.menu.connect( this, {
- menuItemClick: 'onMenuItemClick',
- menuItemDelete: 'onMenuItemRemove',
- menuItemDefault: 'onMenuItemDefault',
- menuItemEdit: 'onMenuItemEdit'
- } );
-
- this.placeholderItem.toggle( this.model.isEmpty() );
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
- .append( this.button.$element );
- };
-
- /* Initialization */
- OO.inheritClass( SavedLinksListWidget, OO.ui.Widget );
-
- /* Methods */
-
- /**
- * Respond to menu item click event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
- this.controller.applySavedQuery( item.getID() );
- this.button.popup.toggle( false );
- };
-
- /**
- * Respond to menu item remove event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
- this.controller.removeSavedQuery( item.getID() );
- };
-
- /**
- * Respond to menu item default event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- * @param {boolean} isDefault Item is default
- */
- SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
- this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
- };
-
- /**
- * Respond to menu item edit event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- * @param {string} newLabel New label
- */
- SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
- this.controller.renameSavedQuery( item.getID(), newLabel );
- };
-
- /**
- * Respond to menu add item event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
- if ( this.menu.findItemFromData( item.getID() ) ) {
- return;
+var GroupWidget = require( './GroupWidget.js' ),
+ SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
+ SavedLinksListWidget;
+
+/**
+ * Quick links widget
+ *
+ * @class mw.rcfilters.ui.SavedLinksListWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+ * @param {Object} [config] Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
+ var $labelNoEntries = $( '<div>' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
+ .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
+ .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
+ );
+
+ config = config || {};
+
+ // Parent
+ SavedLinksListWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+ this.$overlay = config.$overlay || this.$element;
+
+ this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
+ label: $labelNoEntries,
+ icon: 'bookmark'
+ } );
+
+ this.menu = new GroupWidget( {
+ events: {
+ click: 'menuItemClick',
+ delete: 'menuItemDelete',
+ default: 'menuItemDefault',
+ edit: 'menuItemEdit'
+ },
+ classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
+ items: [ this.placeholderItem ]
+ } );
+ this.button = new OO.ui.PopupButtonWidget( {
+ classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
+ label: mw.msg( 'rcfilters-quickfilters' ),
+ icon: 'bookmark',
+ indicator: 'down',
+ $overlay: this.$overlay,
+ popup: {
+ width: 300,
+ anchor: false,
+ align: 'backwards',
+ $autoCloseIgnore: this.$overlay,
+ $content: this.menu.$element
}
-
- this.menu.addItems( [
- new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
- ] );
- this.placeholderItem.toggle( this.model.isEmpty() );
- };
-
- /**
- * Respond to menu remove item event
- *
- * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
- */
- SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
- this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
- this.placeholderItem.toggle( this.model.isEmpty() );
- };
-
- module.exports = SavedLinksListWidget;
-}() );
+ } );
+
+ // Events
+ this.model.connect( this, {
+ add: 'onModelAddItem',
+ remove: 'onModelRemoveItem'
+ } );
+ this.menu.connect( this, {
+ menuItemClick: 'onMenuItemClick',
+ menuItemDelete: 'onMenuItemRemove',
+ menuItemDefault: 'onMenuItemDefault',
+ menuItemEdit: 'onMenuItemEdit'
+ } );
+
+ this.placeholderItem.toggle( this.model.isEmpty() );
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
+ .append( this.button.$element );
+};
+
+/* Initialization */
+OO.inheritClass( SavedLinksListWidget, OO.ui.Widget );
+
+/* Methods */
+
+/**
+ * Respond to menu item click event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
+ this.controller.applySavedQuery( item.getID() );
+ this.button.popup.toggle( false );
+};
+
+/**
+ * Respond to menu item remove event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
+ this.controller.removeSavedQuery( item.getID() );
+};
+
+/**
+ * Respond to menu item default event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ * @param {boolean} isDefault Item is default
+ */
+SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
+ this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
+};
+
+/**
+ * Respond to menu item edit event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ * @param {string} newLabel New label
+ */
+SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
+ this.controller.renameSavedQuery( item.getID(), newLabel );
+};
+
+/**
+ * Respond to menu add item event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
+ if ( this.menu.findItemFromData( item.getID() ) ) {
+ return;
+ }
+
+ this.menu.addItems( [
+ new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
+ ] );
+ this.placeholderItem.toggle( this.model.isEmpty() );
+};
+
+/**
+ * Respond to menu remove item event
+ *
+ * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+ */
+SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
+ this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
+ this.placeholderItem.toggle( this.model.isEmpty() );
+};
+
+module.exports = SavedLinksListWidget;
-( function () {
- /**
- * Extend OOUI's TagItemWidget to also display a popup on hover.
- *
- * @class mw.rcfilters.ui.TagItemWidget
- * @extends OO.ui.TagItemWidget
- * @mixins OO.ui.mixin.PopupElement
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
- * @param {mw.rcfilters.dm.FilterItem} invertModel
- * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
- * @param {Object} config Configuration object
- * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
- */
- var TagItemWidget = function MwRcfiltersUiTagItemWidget(
- controller, filtersViewModel, invertModel, itemModel, config
- ) {
- // Configuration initialization
- config = config || {};
-
- this.controller = controller;
- this.invertModel = invertModel;
- this.filtersViewModel = filtersViewModel;
- this.itemModel = itemModel;
- this.selected = false;
-
- TagItemWidget.parent.call( this, $.extend( {
- data: this.itemModel.getName()
- }, config ) );
-
- this.$overlay = config.$overlay || this.$element;
- this.popupLabel = new OO.ui.LabelWidget();
-
- // Mixin constructors
- OO.ui.mixin.PopupElement.call( this, $.extend( {
- popup: {
- padded: false,
- align: 'center',
- position: 'above',
- $content: $( '<div>' )
- .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
- .append( this.popupLabel.$element ),
- $floatableContainer: this.$element,
- classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
- }
- }, config ) );
-
- this.popupTimeoutShow = null;
- this.popupTimeoutHide = null;
-
- this.$highlight = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
-
- // Add title attribute with the item label to 'x' button
- this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
-
- // Events
- this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
- this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
- this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-
- // Initialization
- this.$overlay.append( this.popup.$element );
- this.$element
- .addClass( 'mw-rcfilters-ui-tagItemWidget' )
- .prepend( this.$highlight )
- .attr( 'aria-haspopup', 'true' )
- .on( 'mouseenter', this.onMouseEnter.bind( this ) )
- .on( 'mouseleave', this.onMouseLeave.bind( this ) );
-
- this.updateUiBasedOnState();
- };
-
- /* Initialization */
-
- OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
- OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );
-
- /* Methods */
-
- /**
- * Respond to model update event
- */
- TagItemWidget.prototype.updateUiBasedOnState = function () {
- // Update label if needed
- var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
- if ( labelMsg ) {
- this.setLabel( $( '<div>' ).append(
- $( '<bdi>' ).html(
- mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
- )
- ).contents() );
- } else {
- this.setLabel(
- $( '<bdi>' ).append(
- this.itemModel.getLabel()
- )
- );
- }
-
- this.setCurrentMuteState();
- this.setHighlightColor();
- };
-
- /**
- * Set the current highlight color for this item
- */
- TagItemWidget.prototype.setHighlightColor = function () {
- var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
- this.itemModel.getHighlightColor() :
- null;
-
- this.$highlight
- .attr( 'data-color', selectedColor )
- .toggleClass(
- 'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
- !!selectedColor
- );
- };
-
- /**
- * Set the current mute state for this item
- */
- TagItemWidget.prototype.setCurrentMuteState = function () {};
-
- /**
- * Respond to mouse enter event
- */
- TagItemWidget.prototype.onMouseEnter = function () {
- var labelText = this.itemModel.getStateMessage();
-
- if ( labelText ) {
- this.popupLabel.setLabel( labelText );
-
- // Set timeout for the popup to show
- this.popupTimeoutShow = setTimeout( function () {
- this.popup.toggle( true );
- }.bind( this ), 500 );
-
- // Cancel the hide timeout
- clearTimeout( this.popupTimeoutHide );
- this.popupTimeoutHide = null;
- }
- };
-
- /**
- * Respond to mouse leave event
- */
- TagItemWidget.prototype.onMouseLeave = function () {
- this.popupTimeoutHide = setTimeout( function () {
- this.popup.toggle( false );
- }.bind( this ), 250 );
-
- // Clear the show timeout
- clearTimeout( this.popupTimeoutShow );
- this.popupTimeoutShow = null;
- };
-
- /**
- * Set selected state on this widget
- *
- * @param {boolean} [isSelected] Widget is selected
- */
- TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
- isSelected = isSelected !== undefined ? isSelected : !this.selected;
-
- if ( this.selected !== isSelected ) {
- this.selected = isSelected;
-
- this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
+/**
+ * Extend OOUI's TagItemWidget to also display a popup on hover.
+ *
+ * @class mw.rcfilters.ui.TagItemWidget
+ * @extends OO.ui.TagItemWidget
+ * @mixins OO.ui.mixin.PopupElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+ * @param {mw.rcfilters.dm.FilterItem} invertModel
+ * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+ * @param {Object} config Configuration object
+ * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+ */
+var TagItemWidget = function MwRcfiltersUiTagItemWidget(
+ controller, filtersViewModel, invertModel, itemModel, config
+) {
+ // Configuration initialization
+ config = config || {};
+
+ this.controller = controller;
+ this.invertModel = invertModel;
+ this.filtersViewModel = filtersViewModel;
+ this.itemModel = itemModel;
+ this.selected = false;
+
+ TagItemWidget.parent.call( this, $.extend( {
+ data: this.itemModel.getName()
+ }, config ) );
+
+ this.$overlay = config.$overlay || this.$element;
+ this.popupLabel = new OO.ui.LabelWidget();
+
+ // Mixin constructors
+ OO.ui.mixin.PopupElement.call( this, $.extend( {
+ popup: {
+ padded: false,
+ align: 'center',
+ position: 'above',
+ $content: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
+ .append( this.popupLabel.$element ),
+ $floatableContainer: this.$element,
+ classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
}
- };
-
- /**
- * Get the selected state of this widget
- *
- * @return {boolean} Tag is selected
- */
- TagItemWidget.prototype.isSelected = function () {
- return this.selected;
- };
-
- /**
- * Get item name
- *
- * @return {string} Filter name
- */
- TagItemWidget.prototype.getName = function () {
- return this.itemModel.getName();
- };
-
- /**
- * Get item model
- *
- * @return {string} Filter model
- */
- TagItemWidget.prototype.getModel = function () {
- return this.itemModel;
- };
-
- /**
- * Get item view
- *
- * @return {string} Filter view
- */
- TagItemWidget.prototype.getView = function () {
- return this.itemModel.getGroupModel().getView();
- };
-
- /**
- * Remove and destroy external elements of this widget
- */
- TagItemWidget.prototype.destroy = function () {
- // Destroy the popup
- this.popup.$element.detach();
-
- // Disconnect events
- this.itemModel.disconnect( this );
- this.closeButton.disconnect( this );
- };
-
- module.exports = TagItemWidget;
-}() );
+ }, config ) );
+
+ this.popupTimeoutShow = null;
+ this.popupTimeoutHide = null;
+
+ this.$highlight = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
+
+ // Add title attribute with the item label to 'x' button
+ this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
+
+ // Events
+ this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+ this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+ this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+
+ // Initialization
+ this.$overlay.append( this.popup.$element );
+ this.$element
+ .addClass( 'mw-rcfilters-ui-tagItemWidget' )
+ .prepend( this.$highlight )
+ .attr( 'aria-haspopup', 'true' )
+ .on( 'mouseenter', this.onMouseEnter.bind( this ) )
+ .on( 'mouseleave', this.onMouseLeave.bind( this ) );
+
+ this.updateUiBasedOnState();
+};
+
+/* Initialization */
+
+OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
+OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+TagItemWidget.prototype.updateUiBasedOnState = function () {
+ // Update label if needed
+ var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
+ if ( labelMsg ) {
+ this.setLabel( $( '<div>' ).append(
+ $( '<bdi>' ).html(
+ mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
+ )
+ ).contents() );
+ } else {
+ this.setLabel(
+ $( '<bdi>' ).append(
+ this.itemModel.getLabel()
+ )
+ );
+ }
+
+ this.setCurrentMuteState();
+ this.setHighlightColor();
+};
+
+/**
+ * Set the current highlight color for this item
+ */
+TagItemWidget.prototype.setHighlightColor = function () {
+ var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
+ this.itemModel.getHighlightColor() :
+ null;
+
+ this.$highlight
+ .attr( 'data-color', selectedColor )
+ .toggleClass(
+ 'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
+ !!selectedColor
+ );
+};
+
+/**
+ * Set the current mute state for this item
+ */
+TagItemWidget.prototype.setCurrentMuteState = function () {};
+
+/**
+ * Respond to mouse enter event
+ */
+TagItemWidget.prototype.onMouseEnter = function () {
+ var labelText = this.itemModel.getStateMessage();
+
+ if ( labelText ) {
+ this.popupLabel.setLabel( labelText );
+
+ // Set timeout for the popup to show
+ this.popupTimeoutShow = setTimeout( function () {
+ this.popup.toggle( true );
+ }.bind( this ), 500 );
+
+ // Cancel the hide timeout
+ clearTimeout( this.popupTimeoutHide );
+ this.popupTimeoutHide = null;
+ }
+};
+
+/**
+ * Respond to mouse leave event
+ */
+TagItemWidget.prototype.onMouseLeave = function () {
+ this.popupTimeoutHide = setTimeout( function () {
+ this.popup.toggle( false );
+ }.bind( this ), 250 );
+
+ // Clear the show timeout
+ clearTimeout( this.popupTimeoutShow );
+ this.popupTimeoutShow = null;
+};
+
+/**
+ * Set selected state on this widget
+ *
+ * @param {boolean} [isSelected] Widget is selected
+ */
+TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
+ isSelected = isSelected !== undefined ? isSelected : !this.selected;
+
+ if ( this.selected !== isSelected ) {
+ this.selected = isSelected;
+
+ this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
+ }
+};
+
+/**
+ * Get the selected state of this widget
+ *
+ * @return {boolean} Tag is selected
+ */
+TagItemWidget.prototype.isSelected = function () {
+ return this.selected;
+};
+
+/**
+ * Get item name
+ *
+ * @return {string} Filter name
+ */
+TagItemWidget.prototype.getName = function () {
+ return this.itemModel.getName();
+};
+
+/**
+ * Get item model
+ *
+ * @return {string} Filter model
+ */
+TagItemWidget.prototype.getModel = function () {
+ return this.itemModel;
+};
+
+/**
+ * Get item view
+ *
+ * @return {string} Filter view
+ */
+TagItemWidget.prototype.getView = function () {
+ return this.itemModel.getGroupModel().getView();
+};
+
+/**
+ * Remove and destroy external elements of this widget
+ */
+TagItemWidget.prototype.destroy = function () {
+ // Destroy the popup
+ this.popup.$element.detach();
+
+ // Disconnect events
+ this.itemModel.disconnect( this );
+ this.closeButton.disconnect( this );
+};
+
+module.exports = TagItemWidget;
-( function () {
- /**
- * Widget defining the behavior used to choose from a set of values
- * in a single_value group
- *
- * @class mw.rcfilters.ui.ValuePickerWidget
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- *
- * @constructor
- * @param {mw.rcfilters.dm.FilterGroup} model Group model
- * @param {Object} [config] Configuration object
- * @cfg {Function} [itemFilter] A filter function for the items from the
- * model. If not given, all items will be included. The function must
- * handle item models and return a boolean whether the item is included
- * or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
- */
- var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
- config = config || {};
-
- // Parent
- ValuePickerWidget.parent.call( this, config );
- // Mixin constructors
- OO.ui.mixin.LabelElement.call( this, config );
-
- this.model = model;
- this.itemFilter = config.itemFilter || function () {
- return true;
- };
-
- // Build the selection from the item models
- this.selectWidget = new OO.ui.ButtonSelectWidget();
- this.initializeSelectWidget();
-
- // Events
- this.model.connect( this, { update: 'onModelUpdate' } );
- this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
-
- // Initialize
- this.$element
- .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
- .append(
- this.$label
- .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
- this.selectWidget.$element
- );
+/**
+ * Widget defining the behavior used to choose from a set of values
+ * in a single_value group
+ *
+ * @class mw.rcfilters.ui.ValuePickerWidget
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.dm.FilterGroup} model Group model
+ * @param {Object} [config] Configuration object
+ * @cfg {Function} [itemFilter] A filter function for the items from the
+ * model. If not given, all items will be included. The function must
+ * handle item models and return a boolean whether the item is included
+ * or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
+ */
+var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
+ config = config || {};
+
+ // Parent
+ ValuePickerWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.LabelElement.call( this, config );
+
+ this.model = model;
+ this.itemFilter = config.itemFilter || function () {
+ return true;
};
- /* Initialization */
-
- OO.inheritClass( ValuePickerWidget, OO.ui.Widget );
- OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );
-
- /* Events */
-
- /**
- * @event choose
- * @param {string} name Item name
- *
- * An item has been chosen
- */
-
- /* Methods */
-
- /**
- * Respond to model update event
- */
- ValuePickerWidget.prototype.onModelUpdate = function () {
- this.selectCurrentModelItem();
- };
-
- /**
- * Respond to select widget choose event
- *
- * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
- * @fires choose
- */
- ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
- this.emit( 'choose', chosenItem.getData() );
- };
-
- /**
- * Initialize the select widget
- */
- ValuePickerWidget.prototype.initializeSelectWidget = function () {
- var items = this.model.getItems()
- .filter( this.itemFilter )
- .map( function ( filterItem ) {
- return new OO.ui.ButtonOptionWidget( {
- data: filterItem.getName(),
- label: filterItem.getLabel()
- } );
+ // Build the selection from the item models
+ this.selectWidget = new OO.ui.ButtonSelectWidget();
+ this.initializeSelectWidget();
+
+ // Events
+ this.model.connect( this, { update: 'onModelUpdate' } );
+ this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
+
+ // Initialize
+ this.$element
+ .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
+ .append(
+ this.$label
+ .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
+ this.selectWidget.$element
+ );
+};
+
+/* Initialization */
+
+OO.inheritClass( ValuePickerWidget, OO.ui.Widget );
+OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );
+
+/* Events */
+
+/**
+ * @event choose
+ * @param {string} name Item name
+ *
+ * An item has been chosen
+ */
+
+/* Methods */
+
+/**
+ * Respond to model update event
+ */
+ValuePickerWidget.prototype.onModelUpdate = function () {
+ this.selectCurrentModelItem();
+};
+
+/**
+ * Respond to select widget choose event
+ *
+ * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
+ * @fires choose
+ */
+ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
+ this.emit( 'choose', chosenItem.getData() );
+};
+
+/**
+ * Initialize the select widget
+ */
+ValuePickerWidget.prototype.initializeSelectWidget = function () {
+ var items = this.model.getItems()
+ .filter( this.itemFilter )
+ .map( function ( filterItem ) {
+ return new OO.ui.ButtonOptionWidget( {
+ data: filterItem.getName(),
+ label: filterItem.getLabel()
} );
+ } );
- this.selectWidget.clearItems();
- this.selectWidget.addItems( items );
+ this.selectWidget.clearItems();
+ this.selectWidget.addItems( items );
- this.selectCurrentModelItem();
- };
+ this.selectCurrentModelItem();
+};
- /**
- * Select the current item that corresponds with the model item
- * that is currently selected
- */
- ValuePickerWidget.prototype.selectCurrentModelItem = function () {
- var selectedItem = this.model.findSelectedItems()[ 0 ];
+/**
+ * Select the current item that corresponds with the model item
+ * that is currently selected
+ */
+ValuePickerWidget.prototype.selectCurrentModelItem = function () {
+ var selectedItem = this.model.findSelectedItems()[ 0 ];
- if ( selectedItem ) {
- this.selectWidget.selectItemByData( selectedItem.getName() );
- }
- };
+ if ( selectedItem ) {
+ this.selectWidget.selectItemByData( selectedItem.getName() );
+ }
+};
- module.exports = ValuePickerWidget;
-}() );
+module.exports = ValuePickerWidget;
-( function () {
- var GroupWidget = require( './GroupWidget.js' ),
- ViewSwitchWidget;
+var GroupWidget = require( './GroupWidget.js' ),
+ ViewSwitchWidget;
- /**
- * A widget for the footer for the default view, allowing to switch views
- *
- * @class mw.rcfilters.ui.ViewSwitchWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller Controller
- * @param {mw.rcfilters.dm.FiltersViewModel} model View model
- * @param {Object} [config] Configuration object
- */
- ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
- config = config || {};
+/**
+ * A widget for the footer for the default view, allowing to switch views
+ *
+ * @class mw.rcfilters.ui.ViewSwitchWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} [config] Configuration object
+ */
+ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
+ config = config || {};
- // Parent
- ViewSwitchWidget.parent.call( this, config );
+ // Parent
+ ViewSwitchWidget.parent.call( this, config );
- this.controller = controller;
- this.model = model;
+ this.controller = controller;
+ this.model = model;
- this.buttons = new GroupWidget( {
- events: {
- click: 'buttonClick'
- },
- items: [
- new OO.ui.ButtonWidget( {
- data: 'namespaces',
- icon: 'article',
- label: mw.msg( 'namespaces' )
- } ),
- new OO.ui.ButtonWidget( {
- data: 'tags',
- icon: 'tag',
- label: mw.msg( 'rcfilters-view-tags' )
- } )
- ]
- } );
+ this.buttons = new GroupWidget( {
+ events: {
+ click: 'buttonClick'
+ },
+ items: [
+ new OO.ui.ButtonWidget( {
+ data: 'namespaces',
+ icon: 'article',
+ label: mw.msg( 'namespaces' )
+ } ),
+ new OO.ui.ButtonWidget( {
+ data: 'tags',
+ icon: 'tag',
+ label: mw.msg( 'rcfilters-view-tags' )
+ } )
+ ]
+ } );
- // Events
- this.model.connect( this, { update: 'onModelUpdate' } );
- this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
+ // Events
+ this.model.connect( this, { update: 'onModelUpdate' } );
+ this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
- this.$element
- .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
- .append(
- new OO.ui.LabelWidget( {
- label: mw.msg( 'rcfilters-advancedfilters' )
- } ).$element,
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
- .append( this.buttons.$element )
- );
- };
+ this.$element
+ .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
+ .append(
+ new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-advancedfilters' )
+ } ).$element,
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
+ .append( this.buttons.$element )
+ );
+};
- /* Initialize */
+/* Initialize */
- OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
+OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
- /**
- * Respond to model update event
- */
- ViewSwitchWidget.prototype.onModelUpdate = function () {
- var currentView = this.model.getCurrentView();
+/**
+ * Respond to model update event
+ */
+ViewSwitchWidget.prototype.onModelUpdate = function () {
+ var currentView = this.model.getCurrentView();
- this.buttons.getItems().forEach( function ( buttonWidget ) {
- buttonWidget.setActive( buttonWidget.getData() === currentView );
- } );
- };
+ this.buttons.getItems().forEach( function ( buttonWidget ) {
+ buttonWidget.setActive( buttonWidget.getData() === currentView );
+ } );
+};
- /**
- * Respond to button switch click
- *
- * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
- */
- ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
- this.controller.switchView( buttonWidget.getData() );
- };
+/**
+ * Respond to button switch click
+ *
+ * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
+ */
+ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
+ this.controller.switchView( buttonWidget.getData() );
+};
- module.exports = ViewSwitchWidget;
-}() );
+module.exports = ViewSwitchWidget;
-( function () {
- var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
- WatchlistTopSectionWidget;
- /**
- * Top section (between page title and filters) on Special:Watchlist
- *
- * @class mw.rcfilters.ui.WatchlistTopSectionWidget
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {mw.rcfilters.Controller} controller
- * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
- * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
- * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
- * @param {Object} [config] Configuration object
- */
- WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
- controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
- ) {
- var editWatchlistButton,
- markSeenButton,
- $topTable,
- $bottomTable,
- $separator;
- config = config || {};
+var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
+ WatchlistTopSectionWidget;
+/**
+ * Top section (between page title and filters) on Special:Watchlist
+ *
+ * @class mw.rcfilters.ui.WatchlistTopSectionWidget
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller
+ * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+ * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+ * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
+ * @param {Object} [config] Configuration object
+ */
+WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
+ controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
+) {
+ var editWatchlistButton,
+ markSeenButton,
+ $topTable,
+ $bottomTable,
+ $separator;
+ config = config || {};
- // Parent
- WatchlistTopSectionWidget.parent.call( this, config );
+ // Parent
+ WatchlistTopSectionWidget.parent.call( this, config );
- editWatchlistButton = new OO.ui.ButtonWidget( {
- label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
- icon: 'edit',
- href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl
- } );
- markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );
+ editWatchlistButton = new OO.ui.ButtonWidget( {
+ label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
+ icon: 'edit',
+ href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl
+ } );
+ markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );
- $topTable = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
- .append( $watchlistDetails )
- )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
- .append( editWatchlistButton.$element )
- )
- );
+ $topTable = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
+ .append( $watchlistDetails )
+ )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
+ .append( editWatchlistButton.$element )
+ )
+ );
- $bottomTable = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-table' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-row' )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .append( markSeenButton.$element )
- )
- .append(
- $( '<div>' )
- .addClass( 'mw-rcfilters-ui-cell' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
- .append( savedLinksListWidget.$element )
- )
- );
+ $bottomTable = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-table' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-row' )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .append( markSeenButton.$element )
+ )
+ .append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-cell' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
+ .append( savedLinksListWidget.$element )
+ )
+ );
- $separator = $( '<div>' )
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
+ $separator = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
- this.$element
- .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
- .append( $topTable, $separator, $bottomTable );
- };
+ this.$element
+ .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
+ .append( $topTable, $separator, $bottomTable );
+};
- /* Initialization */
+/* Initialization */
- OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
- module.exports = WatchlistTopSectionWidget;
-}() );
+module.exports = WatchlistTopSectionWidget;
}
$( function () {
- // This code is also loaded on the "block succeeded" page where there is no form,
- // so username and expiry fields might also be missing.
- var blockTargetWidget = infuseIfExists( $( '#mw-bi-target' ) ),
- anonOnlyField = infuseIfExists( $( '#mw-input-wpHardBlock' ).closest( '.oo-ui-fieldLayout' ) ),
- enableAutoblockField = infuseIfExists( $( '#mw-input-wpAutoBlock' ).closest( '.oo-ui-fieldLayout' ) ),
- hideUserWidget = infuseIfExists( $( '#mw-input-wpHideUser' ) ),
- hideUserField = infuseIfExists( $( '#mw-input-wpHideUser' ).closest( '.oo-ui-fieldLayout' ) ),
- watchUserField = infuseIfExists( $( '#mw-input-wpWatch' ).closest( '.oo-ui-fieldLayout' ) ),
- expiryWidget = infuseIfExists( $( '#mw-input-wpExpiry' ) ),
- editingWidget = infuseIfExists( $( '#mw-input-wpEditing' ) ),
- editingRestrictionWidget = infuseIfExists( $( '#mw-input-wpEditingRestriction' ) ),
- preventTalkPageEdit = infuseIfExists( $( '#mw-input-wpDisableUTEdit' ) ),
- pageRestrictionsWidget = infuseIfExists( $( '#mw-input-wpPageRestrictions' ) ),
- namespaceRestrictionsWidget = infuseIfExists( $( '#mw-input-wpNamespaceRestrictions' ) ),
- createAccountWidget = infuseIfExists( $( '#mw-input-wpCreateAccount' ) ),
- userChangedCreateAccount = mw.config.get( 'wgCreateAccountDirty' ),
- updatingBlockOptions = false;
+ var blockTargetWidget, anonOnlyWidget, enableAutoblockWidget, hideUserWidget, watchUserWidget,
+ expiryWidget, editingWidget, editingRestrictionWidget, preventTalkPageEditWidget,
+ pageRestrictionsWidget, namespaceRestrictionsWidget, createAccountWidget, data,
+ enablePartialBlocks, blockAllowsUTEdit, userChangedCreateAccount, updatingBlockOptions;
function updateBlockOptions() {
var blocktarget = blockTargetWidget.getValue().trim(),
// infinityValues are the values the SpecialBlock class accepts as infinity (sf. wfIsInfinity)
infinityValues = [ 'infinite', 'indefinite', 'infinity', 'never' ],
isIndefinite = infinityValues.indexOf( expiryValue ) !== -1,
- // editingRestrictionWidget only exists if partial blocks is enabled; if not, block must be sitewide
- editingRestrictionValue = editingRestrictionWidget ? editingRestrictionWidget.getValue() : 'sitewide',
- editingIsSelected = editingWidget ? editingWidget.isSelected() : false,
+ editingRestrictionValue = enablePartialBlocks ? editingRestrictionWidget.getValue() : 'sitewide',
+ editingIsSelected = editingWidget.isSelected(),
isSitewide = editingIsSelected && editingRestrictionValue === 'sitewide';
- if ( enableAutoblockField ) {
- enableAutoblockField.toggle( !isNonEmptyIp );
+ enableAutoblockWidget.setDisabled( isNonEmptyIp );
+ if ( enableAutoblockWidget.isDisabled() ) {
+ enableAutoblockWidget.setSelected( false );
+ }
+
+ anonOnlyWidget.setDisabled( !isIp && !isEmpty );
+ if ( anonOnlyWidget.isDisabled() ) {
+ anonOnlyWidget.setSelected( false );
}
- if ( hideUserField ) {
- hideUserField.toggle( !isNonEmptyIp && isIndefinite && isSitewide );
- if ( !hideUserField.isVisible() ) {
+
+ if ( hideUserWidget ) {
+ hideUserWidget.setDisabled( isNonEmptyIp || !isIndefinite || !isSitewide );
+ if ( hideUserWidget.isDisabled() ) {
hideUserWidget.setSelected( false );
}
}
- if ( anonOnlyField ) {
- anonOnlyField.toggle( isIp || isEmpty );
- }
- if ( watchUserField ) {
- watchUserField.toggle( !isIpRange || isEmpty );
+
+ if ( watchUserWidget ) {
+ watchUserWidget.setDisabled( isIpRange && !isEmpty );
+ if ( watchUserWidget.isDisabled() ) {
+ watchUserWidget.setSelected( false );
+ }
}
- if ( editingRestrictionWidget ) {
+
+ if ( enablePartialBlocks ) {
editingRestrictionWidget.setDisabled( !editingIsSelected );
- }
- if ( pageRestrictionsWidget ) {
pageRestrictionsWidget.setDisabled( !editingIsSelected || isSitewide );
- }
- if ( namespaceRestrictionsWidget ) {
namespaceRestrictionsWidget.setDisabled( !editingIsSelected || isSitewide );
+ if ( blockAllowsUTEdit ) {
+ // This option is disabled for partial blocks unless a namespace restriction
+ // for the User_talk namespace is in place.
+ preventTalkPageEditWidget.setDisabled(
+ editingIsSelected &&
+ editingRestrictionValue === 'partial' &&
+ namespaceRestrictionsWidget.getValue().indexOf(
+ String( mw.config.get( 'wgNamespaceIds' ).user_talk )
+ ) === -1
+ );
+ }
}
- if ( preventTalkPageEdit && namespaceRestrictionsWidget ) {
- // This option is disabled for partial blocks unless a namespace restriction
- // for the User_talk namespace is in place.
- preventTalkPageEdit.setDisabled(
- editingIsSelected &&
- editingRestrictionValue === 'partial' &&
- namespaceRestrictionsWidget.getValue().indexOf(
- String( mw.config.get( 'wgNamespaceIds' ).user_talk )
- ) === -1
- );
- }
+
if ( !userChangedCreateAccount ) {
updatingBlockOptions = true;
createAccountWidget.setSelected( isSitewide );
}
+ // This code is also loaded on the "block succeeded" page where there is no form,
+ // so check for block target widget; if it exists, the form is present
+ blockTargetWidget = infuseIfExists( $( '#mw-bi-target' ) );
+
if ( blockTargetWidget ) {
- // Bind functions so they're checked whenever stuff changes
+ data = require( './config.json' );
+ enablePartialBlocks = data.EnablePartialBlocks;
+ blockAllowsUTEdit = data.BlockAllowsUTEdit;
+ userChangedCreateAccount = mw.config.get( 'wgCreateAccountDirty' );
+ updatingBlockOptions = false;
+
+ // Always present if blockTargetWidget is present
+ editingWidget = OO.ui.infuse( $( '#mw-input-wpEditing' ) );
+ expiryWidget = OO.ui.infuse( $( '#mw-input-wpExpiry' ) );
+ createAccountWidget = OO.ui.infuse( $( '#mw-input-wpCreateAccount' ) );
+ enableAutoblockWidget = OO.ui.infuse( $( '#mw-input-wpAutoBlock' ) );
+ anonOnlyWidget = OO.ui.infuse( $( '#mw-input-wpHardBlock' ) );
blockTargetWidget.on( 'change', updateBlockOptions );
+ editingWidget.on( 'change', updateBlockOptions );
expiryWidget.on( 'change', updateBlockOptions );
- if ( editingWidget ) {
- editingWidget.on( 'change', updateBlockOptions );
- }
- if ( editingRestrictionWidget ) {
- editingRestrictionWidget.on( 'change', updateBlockOptions );
- }
- if ( namespaceRestrictionsWidget ) {
- namespaceRestrictionsWidget.on( 'change', updateBlockOptions );
- }
-
createAccountWidget.on( 'change', function () {
if ( !updatingBlockOptions ) {
userChangedCreateAccount = true;
}
} );
- // Call them now to set initial state (ie. Special:Block/Foobar?wpBlockExpiry=2+hours)
+ // Present for certain rights
+ watchUserWidget = infuseIfExists( $( '#mw-input-wpWatch' ) );
+ hideUserWidget = infuseIfExists( $( '#mw-input-wpHideUser' ) );
+
+ // Present for certain global configs
+ if ( enablePartialBlocks ) {
+ editingRestrictionWidget = OO.ui.infuse( $( '#mw-input-wpEditingRestriction' ) );
+ pageRestrictionsWidget = OO.ui.infuse( $( '#mw-input-wpPageRestrictions' ) );
+ namespaceRestrictionsWidget = OO.ui.infuse( $( '#mw-input-wpNamespaceRestrictions' ) );
+ editingRestrictionWidget.on( 'change', updateBlockOptions );
+ namespaceRestrictionsWidget.on( 'change', updateBlockOptions );
+ }
+ if ( blockAllowsUTEdit ) {
+ preventTalkPageEditWidget = infuseIfExists( $( '#mw-input-wpDisableUTEdit' ) );
+ }
+
updateBlockOptions();
}
} );
.mw-widget-dateInputWidget {
&.oo-ui-textInputWidget {
display: inline-block;
- width: 21em;
+ max-width: 21em;
// .oo-ui-inline-spacing( 0.5em ); already inherited from `.oo-ui-inputWidget`
.oo-ui-labelElement-label {
* "text/javascript"; if no type is provided, text/javascript is assumed.
*/
load: function ( modules, type ) {
- var filtered, l;
+ var l;
// Allow calling with a url or single dependency as a string
if ( typeof modules === 'string' ) {
modules = [ modules ];
}
- // Filter out top-level modules that are unknown or failed to load before.
- filtered = modules.filter( function ( module ) {
- var state = mw.loader.getState( module );
- return state !== 'error' && state !== 'missing';
- } );
- // Resolve remaining list using the known dependency tree.
- // This also filters out modules with unknown dependencies. (T36853)
- filtered = resolveStubbornly( filtered );
- // Some modules are not yet ready, add to module load queue.
- enqueue( filtered, undefined, undefined );
+ // Resolve modules into flat list for internal queuing.
+ // This also filters out unknown modules and modules with
+ // unknown dependencies, allowing the rest to continue. (T36853)
+ enqueue( resolveStubbornly( modules ), undefined, undefined );
},
/**
*
* Other browsers that pass the check are considered Grade X.
*
- * @param {string} [str] User agent, defaults to navigator.userAgent
+ * @private
+ * @param {string} ua User agent string
* @return {boolean} User agent is compatible with MediaWiki JS
*/
-function isCompatible( str ) {
- var ua = str || navigator.userAgent;
+function isCompatible( ua ) {
return !!(
// https://caniuse.com/#feat=es5
// https://caniuse.com/#feat=use-strict
);
}
-if ( !isCompatible() ) {
+if ( !isCompatible( navigator.userAgent ) ) {
// Handle Grade C
// Undo speculative Grade A <html> class. See ResourceLoaderClientHtml::getDocumentAttributes().
document.documentElement.className = document.documentElement.className
100 => 'MemoryAlpha',
101 => 'MemoryAlpha_talk'
];
- // Changing wgExtraNamespaces invalidates caches in MWNamespace and
- // any live Language object, both on setup and teardown
+ // Changing wgExtraNamespaces invalidates caches in NamespaceInfo and any live Language
+ // object, both on setup and teardown
$reset = function () {
- MWNamespace::clearCaches();
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'NamespaceInfo' );
MediaWikiServices::getInstance()->getContentLanguage()->resetNamespaces();
};
$setup[] = $reset;
</p>
!! end
+!! test
+formatdate with invalid month
+!! wikitext
+{{#formatdate:2019-22-22|dmy}}
+!! html
+<p>2019-22-22
+</p>
+!! end
+
+!! test
+formatdate: dots in month name do not match any char (T220563)
+!! options
+language=de
+!! wikitext
+{{#formatdate:jun. 3|dmy}}
+{{#formatdate:junx 3|dmy}}
+!! html
+<p><span class="mw-formatted-date" title="06-03">3 Juni</span>
+junx 3
+</p>
+!! end
+
+!! test
+formatdate uses correct capitalisation in French
+!! options
+language=fr
+!! wikitext
+{{#formatdate:Juin 3|dmy}}
+!! html
+<p><span class="mw-formatted-date" title="06-03">3 juin</span>
+</p>
+!! end
+
+!! test
+formatdate uses correct capitalisation in English
+!! wikitext
+{{#formatdate:june 3|dmy}}
+!! html
+<p><span class="mw-formatted-date" title="06-03">3 June</span>
+</p>
+!! end
+
+
#
#
#
$this->tmpFiles = array_merge( $this->tmpFiles, (array)$files );
}
+ // @todo Make const when we no longer support HHVM (T192166)
+ private static $namespaceAffectingSettings = [
+ 'wgAllowImageMoving',
+ 'wgCanonicalNamespaceNames',
+ 'wgCapitalLinkOverrides',
+ 'wgCapitalLinks',
+ 'wgContentNamespaces',
+ 'wgExtensionMessagesFiles',
+ 'wgExtensionNamespaces',
+ 'wgExtraNamespaces',
+ 'wgExtraSignatureNamespaces',
+ 'wgNamespaceContentModels',
+ 'wgNamespaceProtection',
+ 'wgNamespacesWithSubpages',
+ 'wgNonincludableNamespaces',
+ 'wgRestrictionLevels',
+ ];
+
protected function tearDown() {
global $wgRequest, $wgSQLMode;
ini_set( $name, $value );
}
if (
- array_key_exists( 'wgExtraNamespaces', $this->mwGlobals ) ||
- in_array( 'wgExtraNamespaces', $this->mwGlobalsToUnset )
+ array_intersect( self::$namespaceAffectingSettings, array_keys( $this->mwGlobals ) ) ||
+ array_intersect( self::$namespaceAffectingSettings, $this->mwGlobalsToUnset )
) {
$this->resetNamespaces();
}
$GLOBALS[$key] = $value;
}
- if ( array_key_exists( 'wgExtraNamespaces', $pairs ) ) {
+ if ( array_intersect( self::$namespaceAffectingSettings, array_keys( $pairs ) ) ) {
$this->resetNamespaces();
}
}
. 'instance has been replaced by test code.' );
}
- MWNamespace::clearCaches();
Language::clearCaches();
-
- // We can't have the TitleFormatter holding on to an old Language object either
- // @todo We shouldn't need to reset all the aliases here.
- $this->localServices->resetServiceForTesting( 'TitleFormatter' );
- $this->localServices->resetServiceForTesting( 'TitleParser' );
- $this->localServices->resetServiceForTesting( '_MediaWikiTitleCodec' );
}
/**
if ( $tablesToRestore ) {
$this->recloneMockTables( $db, $tablesToRestore );
+
+ // Reset the restored tables, mainly for the side effect of
+ // re-calling $this->addCoreDBData() if necessary.
+ $this->resetDB( $db, $tablesToRestore );
}
}
if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
$this->undoSchemaOverrides( $db, $oldOverrides );
+ unset( $db->_schemaOverrides );
}
// Determine new overrides.
if ( array_intersect( $tablesUsed, $userTables ) ) {
$tablesUsed = array_unique( array_merge( $tablesUsed, $userTables ) );
TestUserRegistry::clear();
+
+ // Reset $wgUser, which is probably 127.0.0.1, as its loaded data is probably not valid
+ // @todo Should we start setting $wgUser to something nondeterministic
+ // to encourage tests to be updated to not depend on it?
+ global $wgUser;
+ $wgUser->clearInstanceCache( $wgUser->mFrom );
}
if ( array_intersect( $tablesUsed, $pageTables ) ) {
$tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
unlink( $readOnlyFile );
$this->setMwGlobals( [
+ 'wgReadOnly' => null,
'wgReadOnlyFile' => $readOnlyFile,
'wgUrlProtocols' => [
'http://',
* @covers ::wfReadOnly
*/
public function testReadOnlyEmpty() {
- global $wgReadOnly;
- $wgReadOnly = null;
-
- MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode()->clearCache();
$this->assertFalse( wfReadOnly() );
$this->assertFalse( wfReadOnly() );
}
* @covers ::wfReadOnly
*/
public function testReadOnlySet() {
- global $wgReadOnly, $wgReadOnlyFile;
-
- $readOnlyMode = MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
- $readOnlyMode->clearCache();
+ global $wgReadOnlyFile;
$f = fopen( $wgReadOnlyFile, "wt" );
fwrite( $f, 'Message' );
fclose( $f );
- $wgReadOnly = null; # Check on $wgReadOnlyFile
+
+ // Reset the service to avoid cached results
+ $this->overrideMwServices();
$this->assertTrue( wfReadOnly() );
$this->assertTrue( wfReadOnly() ); # Check cached
-
- unlink( $wgReadOnlyFile );
- $readOnlyMode->clearCache();
- $this->assertFalse( wfReadOnly() );
- $this->assertFalse( wfReadOnly() );
}
/**
*/
public function testReadOnlyGlobalChange() {
$this->assertFalse( wfReadOnlyReason() );
+
$this->setMwGlobals( [
'wgReadOnly' => 'reason'
] );
+ $this->overrideMwServices();
+
$this->assertSame( 'reason', wfReadOnlyReason() );
}
+++ /dev/null
-<?php
-/**
- * @author Antoine Musso
- * @copyright Copyright © 2011, Antoine Musso
- * @file
- */
-
-class MWNamespaceTest extends MediaWikiTestCase {
-
- protected function setUp() {
- parent::setUp();
-
- $this->setMwGlobals( [
- 'wgContentNamespaces' => [ NS_MAIN ],
- 'wgNamespacesWithSubpages' => [
- NS_TALK => true,
- NS_USER => true,
- NS_USER_TALK => true,
- ],
- 'wgCapitalLinks' => true,
- 'wgCapitalLinkOverrides' => [],
- 'wgNonincludableNamespaces' => [],
- ] );
- }
-
- /**
- * @todo Write more texts, handle $wgAllowImageMoving setting
- * @covers MWNamespace::isMovable
- */
- public function testIsMovable() {
- $this->assertFalse( MWNamespace::isMovable( NS_SPECIAL ) );
- }
-
- private function assertIsSubject( $ns ) {
- $this->assertTrue( MWNamespace::isSubject( $ns ) );
- }
-
- private function assertIsNotSubject( $ns ) {
- $this->assertFalse( MWNamespace::isSubject( $ns ) );
- }
-
- /**
- * Please make sure to change testIsTalk() if you change the assertions below
- * @covers MWNamespace::isSubject
- */
- public function testIsSubject() {
- // Special namespaces
- $this->assertIsSubject( NS_MEDIA );
- $this->assertIsSubject( NS_SPECIAL );
-
- // Subject pages
- $this->assertIsSubject( NS_MAIN );
- $this->assertIsSubject( NS_USER );
- $this->assertIsSubject( 100 ); # user defined
-
- // Talk pages
- $this->assertIsNotSubject( NS_TALK );
- $this->assertIsNotSubject( NS_USER_TALK );
- $this->assertIsNotSubject( 101 ); # user defined
- }
-
- private function assertIsTalk( $ns ) {
- $this->assertTrue( MWNamespace::isTalk( $ns ) );
- }
-
- private function assertIsNotTalk( $ns ) {
- $this->assertFalse( MWNamespace::isTalk( $ns ) );
- }
-
- /**
- * Reverse of testIsSubject().
- * Please update testIsSubject() if you change assertions below
- * @covers MWNamespace::isTalk
- */
- public function testIsTalk() {
- // Special namespaces
- $this->assertIsNotTalk( NS_MEDIA );
- $this->assertIsNotTalk( NS_SPECIAL );
-
- // Subject pages
- $this->assertIsNotTalk( NS_MAIN );
- $this->assertIsNotTalk( NS_USER );
- $this->assertIsNotTalk( 100 ); # user defined
-
- // Talk pages
- $this->assertIsTalk( NS_TALK );
- $this->assertIsTalk( NS_USER_TALK );
- $this->assertIsTalk( 101 ); # user defined
- }
-
- /**
- * @covers MWNamespace::getSubject
- */
- public function testGetSubject() {
- // Special namespaces are their own subjects
- $this->assertEquals( NS_MEDIA, MWNamespace::getSubject( NS_MEDIA ) );
- $this->assertEquals( NS_SPECIAL, MWNamespace::getSubject( NS_SPECIAL ) );
-
- $this->assertEquals( NS_MAIN, MWNamespace::getSubject( NS_TALK ) );
- $this->assertEquals( NS_USER, MWNamespace::getSubject( NS_USER_TALK ) );
- }
-
- /**
- * Regular getTalk() calls
- * Namespaces without a talk page (NS_MEDIA, NS_SPECIAL) are tested in
- * the function testGetTalkExceptions()
- * @covers MWNamespace::getTalk
- */
- public function testGetTalk() {
- $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_MAIN ) );
- $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_TALK ) );
- $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER ) );
- $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER_TALK ) );
- }
-
- /**
- * Exceptions with getTalk()
- * NS_MEDIA does not have talk pages. MediaWiki raise an exception for them.
- * @expectedException MWException
- * @covers MWNamespace::getTalk
- */
- public function testGetTalkExceptionsForNsMedia() {
- $this->assertNull( MWNamespace::getTalk( NS_MEDIA ) );
- }
-
- /**
- * Exceptions with getTalk()
- * NS_SPECIAL does not have talk pages. MediaWiki raise an exception for them.
- * @expectedException MWException
- * @covers MWNamespace::getTalk
- */
- public function testGetTalkExceptionsForNsSpecial() {
- $this->assertNull( MWNamespace::getTalk( NS_SPECIAL ) );
- }
-
- /**
- * Regular getAssociated() calls
- * Namespaces without an associated page (NS_MEDIA, NS_SPECIAL) are tested in
- * the function testGetAssociatedExceptions()
- * @covers MWNamespace::getAssociated
- */
- public function testGetAssociated() {
- $this->assertEquals( NS_TALK, MWNamespace::getAssociated( NS_MAIN ) );
- $this->assertEquals( NS_MAIN, MWNamespace::getAssociated( NS_TALK ) );
- }
-
- # ## Exceptions with getAssociated()
- # ## NS_MEDIA and NS_SPECIAL do not have talk pages. MediaWiki raises
- # ## an exception for them.
- /**
- * @expectedException MWException
- * @covers MWNamespace::getAssociated
- */
- public function testGetAssociatedExceptionsForNsMedia() {
- $this->assertNull( MWNamespace::getAssociated( NS_MEDIA ) );
- }
-
- /**
- * @expectedException MWException
- * @covers MWNamespace::getAssociated
- */
- public function testGetAssociatedExceptionsForNsSpecial() {
- $this->assertNull( MWNamespace::getAssociated( NS_SPECIAL ) );
- }
-
- /**
- * Test MWNamespace::equals
- * Note if we add a namespace registration system with keys like 'MAIN'
- * we should add tests here for equivilance on things like 'MAIN' == 0
- * and 'MAIN' == NS_MAIN.
- * @covers MWNamespace::equals
- */
- public function testEquals() {
- $this->assertTrue( MWNamespace::equals( NS_MAIN, NS_MAIN ) );
- $this->assertTrue( MWNamespace::equals( NS_MAIN, 0 ) ); // In case we make NS_MAIN 'MAIN'
- $this->assertTrue( MWNamespace::equals( NS_USER, NS_USER ) );
- $this->assertTrue( MWNamespace::equals( NS_USER, 2 ) );
- $this->assertTrue( MWNamespace::equals( NS_USER_TALK, NS_USER_TALK ) );
- $this->assertTrue( MWNamespace::equals( NS_SPECIAL, NS_SPECIAL ) );
- $this->assertFalse( MWNamespace::equals( NS_MAIN, NS_TALK ) );
- $this->assertFalse( MWNamespace::equals( NS_USER, NS_USER_TALK ) );
- $this->assertFalse( MWNamespace::equals( NS_PROJECT, NS_TEMPLATE ) );
- }
-
- /**
- * @covers MWNamespace::subjectEquals
- */
- public function testSubjectEquals() {
- $this->assertSameSubject( NS_MAIN, NS_MAIN );
- $this->assertSameSubject( NS_MAIN, 0 ); // In case we make NS_MAIN 'MAIN'
- $this->assertSameSubject( NS_USER, NS_USER );
- $this->assertSameSubject( NS_USER, 2 );
- $this->assertSameSubject( NS_USER_TALK, NS_USER_TALK );
- $this->assertSameSubject( NS_SPECIAL, NS_SPECIAL );
- $this->assertSameSubject( NS_MAIN, NS_TALK );
- $this->assertSameSubject( NS_USER, NS_USER_TALK );
-
- $this->assertDifferentSubject( NS_PROJECT, NS_TEMPLATE );
- $this->assertDifferentSubject( NS_SPECIAL, NS_MAIN );
- }
-
- /**
- * @covers MWNamespace::subjectEquals
- */
- public function testSpecialAndMediaAreDifferentSubjects() {
- $this->assertDifferentSubject(
- NS_MEDIA, NS_SPECIAL,
- "NS_MEDIA and NS_SPECIAL are different subject namespaces"
- );
- $this->assertDifferentSubject(
- NS_SPECIAL, NS_MEDIA,
- "NS_SPECIAL and NS_MEDIA are different subject namespaces"
- );
- }
-
- public function provideHasTalkNamespace() {
- return [
- [ NS_MEDIA, false ],
- [ NS_SPECIAL, false ],
-
- [ NS_MAIN, true ],
- [ NS_TALK, true ],
- [ NS_USER, true ],
- [ NS_USER_TALK, true ],
-
- [ 100, true ],
- [ 101, true ],
- ];
- }
-
- /**
- * @dataProvider provideHasTalkNamespace
- * @covers MWNamespace::hasTalkNamespace
- *
- * @param int $index
- * @param bool $expected
- */
- public function testHasTalkNamespace( $index, $expected ) {
- $actual = MWNamespace::hasTalkNamespace( $index );
- $this->assertSame( $actual, $expected, "NS $index" );
- }
-
- /**
- * @dataProvider provideHasTalkNamespace
- * @covers MWNamespace::canTalk
- *
- * @param int $index
- * @param bool $expected
- */
- public function testCanTalk( $index, $expected ) {
- $this->hideDeprecated( 'MWNamespace::canTalk' );
- $actual = MWNamespace::canTalk( $index );
- $this->assertSame( $actual, $expected, "NS $index" );
- }
-
- private function assertIsContent( $ns ) {
- $this->assertTrue( MWNamespace::isContent( $ns ) );
- }
-
- private function assertIsNotContent( $ns ) {
- $this->assertFalse( MWNamespace::isContent( $ns ) );
- }
-
- /**
- * @covers MWNamespace::isContent
- */
- public function testIsContent() {
- // NS_MAIN is a content namespace per DefaultSettings.php
- // and per function definition.
-
- $this->assertIsContent( NS_MAIN );
-
- // Other namespaces which are not expected to be content
-
- $this->assertIsNotContent( NS_MEDIA );
- $this->assertIsNotContent( NS_SPECIAL );
- $this->assertIsNotContent( NS_TALK );
- $this->assertIsNotContent( NS_USER );
- $this->assertIsNotContent( NS_CATEGORY );
- $this->assertIsNotContent( 100 );
- }
-
- /**
- * Similar to testIsContent() but alters the $wgContentNamespaces
- * global variable.
- * @covers MWNamespace::isContent
- */
- public function testIsContentAdvanced() {
- global $wgContentNamespaces;
-
- // Test that user defined namespace #252 is not content
- $this->assertIsNotContent( 252 );
-
- // Bless namespace # 252 as a content namespace
- $wgContentNamespaces[] = 252;
-
- $this->assertIsContent( 252 );
-
- // Makes sure NS_MAIN was not impacted
- $this->assertIsContent( NS_MAIN );
- }
-
- private function assertIsWatchable( $ns ) {
- $this->assertTrue( MWNamespace::isWatchable( $ns ) );
- }
-
- private function assertIsNotWatchable( $ns ) {
- $this->assertFalse( MWNamespace::isWatchable( $ns ) );
- }
-
- /**
- * @covers MWNamespace::isWatchable
- */
- public function testIsWatchable() {
- // Specials namespaces are not watchable
- $this->assertIsNotWatchable( NS_MEDIA );
- $this->assertIsNotWatchable( NS_SPECIAL );
-
- // Core defined namespaces are watchables
- $this->assertIsWatchable( NS_MAIN );
- $this->assertIsWatchable( NS_TALK );
-
- // Additional, user defined namespaces are watchables
- $this->assertIsWatchable( 100 );
- $this->assertIsWatchable( 101 );
- }
-
- private function assertHasSubpages( $ns ) {
- $this->assertTrue( MWNamespace::hasSubpages( $ns ) );
- }
-
- private function assertHasNotSubpages( $ns ) {
- $this->assertFalse( MWNamespace::hasSubpages( $ns ) );
- }
-
- /**
- * @covers MWNamespace::hasSubpages
- */
- public function testHasSubpages() {
- global $wgNamespacesWithSubpages;
-
- // Special namespaces:
- $this->assertHasNotSubpages( NS_MEDIA );
- $this->assertHasNotSubpages( NS_SPECIAL );
-
- // Namespaces without subpages
- $this->assertHasNotSubpages( NS_MAIN );
-
- $wgNamespacesWithSubpages[NS_MAIN] = true;
- $this->assertHasSubpages( NS_MAIN );
-
- $wgNamespacesWithSubpages[NS_MAIN] = false;
- $this->assertHasNotSubpages( NS_MAIN );
-
- // Some namespaces with subpages
- $this->assertHasSubpages( NS_TALK );
- $this->assertHasSubpages( NS_USER );
- $this->assertHasSubpages( NS_USER_TALK );
- }
-
- /**
- * @covers MWNamespace::getContentNamespaces
- */
- public function testGetContentNamespaces() {
- global $wgContentNamespaces;
-
- $this->assertEquals(
- [ NS_MAIN ],
- MWNamespace::getContentNamespaces(),
- '$wgContentNamespaces is an array with only NS_MAIN by default'
- );
-
- # test !is_array( $wgcontentNamespaces )
- $wgContentNamespaces = '';
- $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
- $wgContentNamespaces = false;
- $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
- $wgContentNamespaces = null;
- $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
- $wgContentNamespaces = 5;
- $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
- # test $wgContentNamespaces === []
- $wgContentNamespaces = [];
- $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
- # test !in_array( NS_MAIN, $wgContentNamespaces )
- $wgContentNamespaces = [ NS_USER, NS_CATEGORY ];
- $this->assertEquals(
- [ NS_MAIN, NS_USER, NS_CATEGORY ],
- MWNamespace::getContentNamespaces(),
- 'NS_MAIN is forced in $wgContentNamespaces even if unwanted'
- );
-
- # test other cases, return $wgcontentNamespaces as is
- $wgContentNamespaces = [ NS_MAIN ];
- $this->assertEquals(
- [ NS_MAIN ],
- MWNamespace::getContentNamespaces()
- );
-
- $wgContentNamespaces = [ NS_MAIN, NS_USER, NS_CATEGORY ];
- $this->assertEquals(
- [ NS_MAIN, NS_USER, NS_CATEGORY ],
- MWNamespace::getContentNamespaces()
- );
- }
-
- /**
- * @covers MWNamespace::getSubjectNamespaces
- */
- public function testGetSubjectNamespaces() {
- $subjectsNS = MWNamespace::getSubjectNamespaces();
- $this->assertContains( NS_MAIN, $subjectsNS,
- "Talk namespaces should have NS_MAIN" );
- $this->assertNotContains( NS_TALK, $subjectsNS,
- "Talk namespaces should have NS_TALK" );
-
- $this->assertNotContains( NS_MEDIA, $subjectsNS,
- "Talk namespaces should not have NS_MEDIA" );
- $this->assertNotContains( NS_SPECIAL, $subjectsNS,
- "Talk namespaces should not have NS_SPECIAL" );
- }
-
- /**
- * @covers MWNamespace::getTalkNamespaces
- */
- public function testGetTalkNamespaces() {
- $talkNS = MWNamespace::getTalkNamespaces();
- $this->assertContains( NS_TALK, $talkNS,
- "Subject namespaces should have NS_TALK" );
- $this->assertNotContains( NS_MAIN, $talkNS,
- "Subject namespaces should not have NS_MAIN" );
-
- $this->assertNotContains( NS_MEDIA, $talkNS,
- "Subject namespaces should not have NS_MEDIA" );
- $this->assertNotContains( NS_SPECIAL, $talkNS,
- "Subject namespaces should not have NS_SPECIAL" );
- }
-
- private function assertIsCapitalized( $ns ) {
- $this->assertTrue( MWNamespace::isCapitalized( $ns ) );
- }
-
- private function assertIsNotCapitalized( $ns ) {
- $this->assertFalse( MWNamespace::isCapitalized( $ns ) );
- }
-
- /**
- * Some namespaces are always capitalized per code definition
- * in MWNamespace::$alwaysCapitalizedNamespaces
- * @covers MWNamespace::isCapitalized
- */
- public function testIsCapitalizedHardcodedAssertions() {
- // NS_MEDIA and NS_FILE are treated the same
- $this->assertEquals(
- MWNamespace::isCapitalized( NS_MEDIA ),
- MWNamespace::isCapitalized( NS_FILE ),
- 'NS_MEDIA and NS_FILE have same capitalization rendering'
- );
-
- // Boths are capitalized by default
- $this->assertIsCapitalized( NS_MEDIA );
- $this->assertIsCapitalized( NS_FILE );
-
- // Always capitalized namespaces
- // @see MWNamespace::$alwaysCapitalizedNamespaces
- $this->assertIsCapitalized( NS_SPECIAL );
- $this->assertIsCapitalized( NS_USER );
- $this->assertIsCapitalized( NS_MEDIAWIKI );
- }
-
- /**
- * Follows up for testIsCapitalizedHardcodedAssertions() but alter the
- * global $wgCapitalLink setting to have extended coverage.
- *
- * MWNamespace::isCapitalized() rely on two global settings:
- * $wgCapitalLinkOverrides = []; by default
- * $wgCapitalLinks = true; by default
- * This function test $wgCapitalLinks
- *
- * Global setting correctness is tested against the NS_PROJECT and
- * NS_PROJECT_TALK namespaces since they are not hardcoded nor specials
- * @covers MWNamespace::isCapitalized
- */
- public function testIsCapitalizedWithWgCapitalLinks() {
- global $wgCapitalLinks;
-
- $this->assertIsCapitalized( NS_PROJECT );
- $this->assertIsCapitalized( NS_PROJECT_TALK );
-
- $wgCapitalLinks = false;
-
- // hardcoded namespaces (see above function) are still capitalized:
- $this->assertIsCapitalized( NS_SPECIAL );
- $this->assertIsCapitalized( NS_USER );
- $this->assertIsCapitalized( NS_MEDIAWIKI );
-
- // setting is correctly applied
- $this->assertIsNotCapitalized( NS_PROJECT );
- $this->assertIsNotCapitalized( NS_PROJECT_TALK );
- }
-
- /**
- * Counter part for MWNamespace::testIsCapitalizedWithWgCapitalLinks() now
- * testing the $wgCapitalLinkOverrides global.
- *
- * @todo split groups of assertions in autonomous testing functions
- * @covers MWNamespace::isCapitalized
- */
- public function testIsCapitalizedWithWgCapitalLinkOverrides() {
- global $wgCapitalLinkOverrides;
-
- // Test default settings
- $this->assertIsCapitalized( NS_PROJECT );
- $this->assertIsCapitalized( NS_PROJECT_TALK );
-
- // hardcoded namespaces (see above function) are capitalized:
- $this->assertIsCapitalized( NS_SPECIAL );
- $this->assertIsCapitalized( NS_USER );
- $this->assertIsCapitalized( NS_MEDIAWIKI );
-
- // Hardcoded namespaces remains capitalized
- $wgCapitalLinkOverrides[NS_SPECIAL] = false;
- $wgCapitalLinkOverrides[NS_USER] = false;
- $wgCapitalLinkOverrides[NS_MEDIAWIKI] = false;
-
- $this->assertIsCapitalized( NS_SPECIAL );
- $this->assertIsCapitalized( NS_USER );
- $this->assertIsCapitalized( NS_MEDIAWIKI );
-
- $wgCapitalLinkOverrides[NS_PROJECT] = false;
- $this->assertIsNotCapitalized( NS_PROJECT );
-
- $wgCapitalLinkOverrides[NS_PROJECT] = true;
- $this->assertIsCapitalized( NS_PROJECT );
-
- unset( $wgCapitalLinkOverrides[NS_PROJECT] );
- $this->assertIsCapitalized( NS_PROJECT );
- }
-
- /**
- * @covers MWNamespace::hasGenderDistinction
- */
- public function testHasGenderDistinction() {
- // Namespaces with gender distinctions
- $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER ) );
- $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER_TALK ) );
-
- // Other ones, "genderless"
- $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MEDIA ) );
- $this->assertFalse( MWNamespace::hasGenderDistinction( NS_SPECIAL ) );
- $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MAIN ) );
- $this->assertFalse( MWNamespace::hasGenderDistinction( NS_TALK ) );
- }
-
- /**
- * @covers MWNamespace::isNonincludable
- */
- public function testIsNonincludable() {
- global $wgNonincludableNamespaces;
-
- $wgNonincludableNamespaces = [ NS_USER ];
-
- $this->assertTrue( MWNamespace::isNonincludable( NS_USER ) );
- $this->assertFalse( MWNamespace::isNonincludable( NS_TEMPLATE ) );
- }
-
- private function assertSameSubject( $ns1, $ns2, $msg = '' ) {
- $this->assertTrue( MWNamespace::subjectEquals( $ns1, $ns2 ), $msg );
- }
-
- private function assertDifferentSubject( $ns1, $ns2, $msg = '' ) {
- $this->assertFalse( MWNamespace::subjectEquals( $ns1, $ns2 ), $msg );
- }
-
- public function provideGetCategoryLinkType() {
- return [
- [ NS_MAIN, 'page' ],
- [ NS_TALK, 'page' ],
- [ NS_USER, 'page' ],
- [ NS_USER_TALK, 'page' ],
-
- [ NS_FILE, 'file' ],
- [ NS_FILE_TALK, 'page' ],
-
- [ NS_CATEGORY, 'subcat' ],
- [ NS_CATEGORY_TALK, 'page' ],
-
- [ 100, 'page' ],
- [ 101, 'page' ],
- ];
- }
-
- /**
- * @dataProvider provideGetCategoryLinkType
- * @covers MWNamespace::getCategoryLinkType
- *
- * @param int $index
- * @param string $expected
- */
- public function testGetCategoryLinkType( $index, $expected ) {
- $actual = MWNamespace::getCategoryLinkType( $index );
- $this->assertSame( $expected, $actual, "NS $index" );
- }
-}
} ) );
$sortedNames = $names;
- sort( $sortedNames );
+ natcasesort( $sortedNames );
$this->assertSame( $sortedNames, $names,
'Please keep service getters sorted alphabetically' );
$rom->setReason( 'override' );
$this->assertSame( 'override', $rom->getReason() );
}
-
- /**
- * @covers ReadOnlyMode::clearCache
- * @covers ConfiguredReadOnlyMode::clearCache
- */
- public function testClearCache() {
- $fileName = $this->getNewTempFile();
- unlink( $fileName );
- $config = new HashConfig( [
- 'ReadOnly' => null,
- 'ReadOnlyFile' => $fileName,
- ] );
- $cro = new ConfiguredReadOnlyMode( $config );
- $lb = $this->createLB( [ 'lbMessage' => false ] );
- $rom = new ReadOnlyMode( $cro, $lb );
-
- $this->assertSame( false, $rom->getReason(), 'initial' );
-
- file_put_contents( $fileName, 'file' );
- $this->assertSame( false, $rom->getReason(), 'stale' );
-
- $rom->clearCache();
- $this->assertSame( 'file', $rom->getReason(), 'fresh' );
- }
}
global $IP;
$services = array_keys( require "$IP/includes/ServiceWiring.php" );
$sortedServices = $services;
- sort( $sortedServices );
+ natcasesort( $sortedServices );
$this->assertSame( $sortedServices, $services,
'Please keep services sorted alphabetically' );
$this->setService( 'MainWANObjectCache', $cache );
$jobq = JobQueueGroup::singleton();
- $jobq->push( new NullJob( Title::newMainPage(), [] ) );
+ $jobq->push( Job::factory( 'null', Title::newMainPage(), [] ) );
$this->assertEquals( 1, SiteStats::jobs(),
'A single job enqueued bumps jobscount stat to 1' );
- $jobq->push( new NullJob( Title::newMainPage(), [] ) );
+ $jobq->push( Job::factory( 'null', Title::newMainPage(), [] ) );
$this->assertEquals( 1, SiteStats::jobs(),
'SiteStats::jobs() count does not reflect addition ' .
'of a second job (cached)'
/**
* @dataProvider provideGetName
*/
- public function testGetName( $cacheBag, $insertCalls, $selectCalls ) {
+ public function testGetName( BagOStuff $cacheBag, $insertCalls, $selectCalls ) {
+ $now = microtime( true );
+ $cacheBag->setMockTime( $now );
// Check for operations to in-memory cache (IMC) and persistent cache (PC)
$store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls );
// Get 1 ID and make sure getName returns correctly
$fooId = $store->acquireId( 'foo' ); // regen PC, set IMC, update IMC, tombstone PC
+ $now += 0.01;
$this->assertSame( 'foo', $store->getName( $fooId ) ); // use IMC
+ $now += 0.01;
// Get another ID and make sure getName returns correctly
$barId = $store->acquireId( 'bar' ); // update IMC, tombstone PC
+ $now += 0.01;
$this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC
+ $now += 0.01;
// Blitz the cache and make sure it still returns
TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
// Blitz the cache again and get another ID and make sure getName returns correctly
TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
$bazId = $store->acquireId( 'baz' ); // set IMC using interim PC, update IMC, tombstone PC
+ $now += 0.01;
$this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
$this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
}
$importRevision->setComment( 'Imported edit' );
$importRevision->setTimestamp( '19991122001122' );
$importRevision->setText( 'IMPORTED OLD TEST' );
- $importRevision->setUsername( 'Alan Smithee' );
+ $importRevision->setUsername( 'ext>Alan Smithee' );
$importer = MediaWikiServices::getInstance()->getWikiRevisionOldRevisionImporterNoUpdates();
$importer->import( $importRevision );
}
function newJob( $i = 0, $rootJob = [] ) {
- return new NullJob( Title::newMainPage(),
+ return Job::factory( 'null', Title::newMainPage(),
[ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 0, 'i' => $i ] + $rootJob );
}
function newDedupedJob( $i = 0, $rootJob = [] ) {
- return new NullJob( Title::newMainPage(),
+ return Job::factory( 'null', Title::newMainPage(),
[ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 1, 'i' => $i ] + $rootJob );
}
}
return [
[
$this->getMockJob( false ),
- 'someCommand ' . $requestId
+ 'someCommand Special: ' . $requestId
],
[
$this->getMockJob( [ 'key' => 'val' ] ),
- 'someCommand key=val ' . $requestId
+ 'someCommand Special: key=val ' . $requestId
],
[
$this->getMockJob( [ 'key' => [ 'inkey' => 'inval' ] ] ),
- 'someCommand key={"inkey":"inval"} ' . $requestId
+ 'someCommand Special: key={"inkey":"inval"} ' . $requestId
],
[
$this->getMockJob( [ 'val1' ] ),
- 'someCommand 0=val1 ' . $requestId
+ 'someCommand Special: 0=val1 ' . $requestId
],
[
$this->getMockJob( [ 'val1', 'val2' ] ),
- 'someCommand 0=val1 1=val2 ' . $requestId
+ 'someCommand Special: 0=val1 1=val2 ' . $requestId
],
[
$this->getMockJob( [ new stdClass() ] ),
- 'someCommand 0=object(stdClass) ' . $requestId
+ 'someCommand Special: 0=object(stdClass) ' . $requestId
],
[
$this->getMockJob( [ $mockToStringObj ] ),
- 'someCommand 0={STRING_OBJ_VAL} ' . $requestId
+ 'someCommand Special: 0={STRING_OBJ_VAL} ' . $requestId
],
[
$this->getMockJob( [
],
"triggeredRecursive" => true
] ),
- 'someCommand pages={"932737":[0,"Robert_James_Waller"]} ' .
+ 'someCommand Special: pages={"932737":[0,"Robert_James_Waller"]} ' .
'rootJobSignature=45868e99bba89064e4483743ebb9b682ef95c1a7 ' .
'rootJobTimestamp=20160309110158 masterPos=' .
'{"file":"db1023-bin.001288","pos":"308257743","asOfTime":' .
}
public function getMockJob( $params ) {
+ $title = new Title();
$mock = $this->getMockForAbstractClass(
Job::class,
- [ 'someCommand', new Title(), $params ],
+ [ 'someCommand', $title, $params ],
'SomeJob'
);
+
return $mock;
}
return [
'class name' => [ 'NullJob' ],
'closure' => [ function ( Title $title, array $params ) {
- return new NullJob( $title, $params );
+ return Job::factory( 'null', $title, $params );
} ],
'function' => [ [ $this, 'newNullJob' ] ],
'static function' => [ self::class . '::staticNullJob' ]
}
public function newNullJob( Title $title, array $params ) {
- return new NullJob( $title, $params );
+ return Job::factory( 'null', $title, $params );
}
public static function staticNullJob( Title $title, array $params ) {
- return new NullJob( $title, $params );
+ return Job::factory( 'null', $title, $params );
+ }
+
+ /**
+ * @covers Job::factory
+ * @covers Job::__construct()
+ */
+ public function testJobSignatureGeneric() {
+ $testPage = Title::makeTitle( NS_PROJECT, 'x' );
+ $blankTitle = Title::makeTitle( NS_SPECIAL, '' );
+ $params = [ 'z' => 1, 'lives' => 1, 'usleep' => 0 ];
+ $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ];
+
+ $job = new NullJob( [ 'namespace' => NS_PROJECT, 'title' => 'x' ] + $params );
+ $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+ $job = Job::factory( 'null', $testPage, $params );
+ $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertJobParamsMatch( $job, $params );
+
+ $job = Job::factory( 'null', $paramsWithTitle );
+ $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+ $job = Job::factory( 'null', $params );
+ $this->assertTrue( $blankTitle->equals( $job->getTitle() ) );
+ $this->assertJobParamsMatch( $job, $params );
+ }
+
+ /**
+ * @covers Job::factory
+ * @covers Job::__construct()
+ */
+ public function testJobSignatureTitleBased() {
+ $testPage = Title::makeTitle( NS_PROJECT, 'x' );
+ $blankTitle = Title::makeTitle( NS_SPECIAL, '' );
+ $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ];
+ $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ];
+
+ $job = new RefreshLinksJob( $testPage, $params );
+ $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertSame( $testPage, $job->getTitle() );
+ $this->assertJobParamsMatch( $job, $paramsWithTitle );
+ $this->assertSame( $testPage, $job->getTitle() );
+
+ $job = Job::factory( 'refreshLinks', $testPage, $params );
+ $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+ $job = Job::factory( 'refreshLinks', $paramsWithTitle );
+ $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertJobParamsMatch( $job, $paramsWithTitle );
+
+ $job = Job::factory( 'refreshLinks', $params );
+ $this->assertTrue( $blankTitle->equals( $job->getTitle() ) );
+ $this->assertJobParamsMatch( $job, $params );
+ }
+
+ /**
+ * @covers Job::factory
+ * @covers Job::__construct()
+ */
+ public function testJobSignatureTitleBasedIncomplete() {
+ $testPage = Title::makeTitle( NS_PROJECT, 'x' );
+ $blankTitle = Title::makeTitle( NS_SPECIAL, '' );
+ $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ];
+
+ $job = new RefreshLinksJob( $testPage, $params + [ 'namespace' => 0 ] );
+ $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertJobParamsMatch( $job, $params + [ 'namespace' => 0 ] );
+
+ $job = new RefreshLinksJob( $testPage, $params + [ 'title' => 'x' ] );
+ $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
+ $this->assertJobParamsMatch( $job, $params + [ 'title' => 'x' ] );
}
+ private function assertJobParamsMatch( IJobSpecification $job, array $params ) {
+ $actual = $job->getParams();
+ unset( $actual['requestId'] );
+
+ $this->assertEquals( $actual, $params );
+ }
}
$this->setMwGlobals( 'wgUpdateRowsPerQuery', 2 );
JobQueueGroup::singleton()->push(
- new ClearUserWatchlistJob(
- null,
- [
- 'userId' => $user->getId(),
- 'maxWatchlistId' => $maxId,
- ]
- )
+ new ClearUserWatchlistJob( [
+ 'userId' => $user->getId(), 'maxWatchlistId' => $maxId,
+ ] )
);
$this->assertEquals( 1, JobQueueGroup::singleton()->getQueueSizes()['clearUserWatchlist'] );
'delete',
'delete',
'testing user 0 deletion',
- '0',
+ null,
'127.0.0.1',
(string)$page->getTitle()->getNamespace(),
$page->getTitle()->getDBkey(),
--- /dev/null
+<?php
+/**
+ * @author Antoine Musso
+ * @copyright Copyright © 2011, Antoine Musso
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+class NamespaceInfoTest extends MediaWikiTestCase {
+
+ /** @var NamespaceInfo */
+ private $obj;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgContentNamespaces' => [ NS_MAIN ],
+ 'wgNamespacesWithSubpages' => [
+ NS_TALK => true,
+ NS_USER => true,
+ NS_USER_TALK => true,
+ ],
+ 'wgCapitalLinks' => true,
+ 'wgCapitalLinkOverrides' => [],
+ 'wgNonincludableNamespaces' => [],
+ ] );
+
+ $this->obj = MediaWikiServices::getInstance()->getNamespaceInfo();
+ }
+
+ /**
+ * @todo Write more texts, handle $wgAllowImageMoving setting
+ * @covers NamespaceInfo::isMovable
+ */
+ public function testIsMovable() {
+ $this->assertFalse( $this->obj->isMovable( NS_SPECIAL ) );
+ }
+
+ private function assertIsSubject( $ns ) {
+ $this->assertTrue( $this->obj->isSubject( $ns ) );
+ }
+
+ private function assertIsNotSubject( $ns ) {
+ $this->assertFalse( $this->obj->isSubject( $ns ) );
+ }
+
+ /**
+ * Please make sure to change testIsTalk() if you change the assertions below
+ * @covers NamespaceInfo::isSubject
+ */
+ public function testIsSubject() {
+ // Special namespaces
+ $this->assertIsSubject( NS_MEDIA );
+ $this->assertIsSubject( NS_SPECIAL );
+
+ // Subject pages
+ $this->assertIsSubject( NS_MAIN );
+ $this->assertIsSubject( NS_USER );
+ $this->assertIsSubject( 100 ); # user defined
+
+ // Talk pages
+ $this->assertIsNotSubject( NS_TALK );
+ $this->assertIsNotSubject( NS_USER_TALK );
+ $this->assertIsNotSubject( 101 ); # user defined
+ }
+
+ private function assertIsTalk( $ns ) {
+ $this->assertTrue( $this->obj->isTalk( $ns ) );
+ }
+
+ private function assertIsNotTalk( $ns ) {
+ $this->assertFalse( $this->obj->isTalk( $ns ) );
+ }
+
+ /**
+ * Reverse of testIsSubject().
+ * Please update testIsSubject() if you change assertions below
+ * @covers NamespaceInfo::isTalk
+ */
+ public function testIsTalk() {
+ // Special namespaces
+ $this->assertIsNotTalk( NS_MEDIA );
+ $this->assertIsNotTalk( NS_SPECIAL );
+
+ // Subject pages
+ $this->assertIsNotTalk( NS_MAIN );
+ $this->assertIsNotTalk( NS_USER );
+ $this->assertIsNotTalk( 100 ); # user defined
+
+ // Talk pages
+ $this->assertIsTalk( NS_TALK );
+ $this->assertIsTalk( NS_USER_TALK );
+ $this->assertIsTalk( 101 ); # user defined
+ }
+
+ /**
+ * @covers NamespaceInfo::getSubject
+ */
+ public function testGetSubject() {
+ // Special namespaces are their own subjects
+ $this->assertEquals( NS_MEDIA, $this->obj->getSubject( NS_MEDIA ) );
+ $this->assertEquals( NS_SPECIAL, $this->obj->getSubject( NS_SPECIAL ) );
+
+ $this->assertEquals( NS_MAIN, $this->obj->getSubject( NS_TALK ) );
+ $this->assertEquals( NS_USER, $this->obj->getSubject( NS_USER_TALK ) );
+ }
+
+ /**
+ * Regular getTalk() calls
+ * Namespaces without a talk page (NS_MEDIA, NS_SPECIAL) are tested in
+ * the function testGetTalkExceptions()
+ * @covers NamespaceInfo::getTalk
+ */
+ public function testGetTalk() {
+ $this->assertEquals( NS_TALK, $this->obj->getTalk( NS_MAIN ) );
+ $this->assertEquals( NS_TALK, $this->obj->getTalk( NS_TALK ) );
+ $this->assertEquals( NS_USER_TALK, $this->obj->getTalk( NS_USER ) );
+ $this->assertEquals( NS_USER_TALK, $this->obj->getTalk( NS_USER_TALK ) );
+ }
+
+ /**
+ * Exceptions with getTalk()
+ * NS_MEDIA does not have talk pages. MediaWiki raise an exception for them.
+ * @expectedException MWException
+ * @covers NamespaceInfo::getTalk
+ */
+ public function testGetTalkExceptionsForNsMedia() {
+ $this->assertNull( $this->obj->getTalk( NS_MEDIA ) );
+ }
+
+ /**
+ * Exceptions with getTalk()
+ * NS_SPECIAL does not have talk pages. MediaWiki raise an exception for them.
+ * @expectedException MWException
+ * @covers NamespaceInfo::getTalk
+ */
+ public function testGetTalkExceptionsForNsSpecial() {
+ $this->assertNull( $this->obj->getTalk( NS_SPECIAL ) );
+ }
+
+ /**
+ * Regular getAssociated() calls
+ * Namespaces without an associated page (NS_MEDIA, NS_SPECIAL) are tested in
+ * the function testGetAssociatedExceptions()
+ * @covers NamespaceInfo::getAssociated
+ */
+ public function testGetAssociated() {
+ $this->assertEquals( NS_TALK, $this->obj->getAssociated( NS_MAIN ) );
+ $this->assertEquals( NS_MAIN, $this->obj->getAssociated( NS_TALK ) );
+ }
+
+ # ## Exceptions with getAssociated()
+ # ## NS_MEDIA and NS_SPECIAL do not have talk pages. MediaWiki raises
+ # ## an exception for them.
+ /**
+ * @expectedException MWException
+ * @covers NamespaceInfo::getAssociated
+ */
+ public function testGetAssociatedExceptionsForNsMedia() {
+ $this->assertNull( $this->obj->getAssociated( NS_MEDIA ) );
+ }
+
+ /**
+ * @expectedException MWException
+ * @covers NamespaceInfo::getAssociated
+ */
+ public function testGetAssociatedExceptionsForNsSpecial() {
+ $this->assertNull( $this->obj->getAssociated( NS_SPECIAL ) );
+ }
+
+ /**
+ * Note if we add a namespace registration system with keys like 'MAIN'
+ * we should add tests here for equivilance on things like 'MAIN' == 0
+ * and 'MAIN' == NS_MAIN.
+ * @covers NamespaceInfo::equals
+ */
+ public function testEquals() {
+ $this->assertTrue( $this->obj->equals( NS_MAIN, NS_MAIN ) );
+ $this->assertTrue( $this->obj->equals( NS_MAIN, 0 ) ); // In case we make NS_MAIN 'MAIN'
+ $this->assertTrue( $this->obj->equals( NS_USER, NS_USER ) );
+ $this->assertTrue( $this->obj->equals( NS_USER, 2 ) );
+ $this->assertTrue( $this->obj->equals( NS_USER_TALK, NS_USER_TALK ) );
+ $this->assertTrue( $this->obj->equals( NS_SPECIAL, NS_SPECIAL ) );
+ $this->assertFalse( $this->obj->equals( NS_MAIN, NS_TALK ) );
+ $this->assertFalse( $this->obj->equals( NS_USER, NS_USER_TALK ) );
+ $this->assertFalse( $this->obj->equals( NS_PROJECT, NS_TEMPLATE ) );
+ }
+
+ /**
+ * @covers NamespaceInfo::subjectEquals
+ */
+ public function testSubjectEquals() {
+ $this->assertSameSubject( NS_MAIN, NS_MAIN );
+ $this->assertSameSubject( NS_MAIN, 0 ); // In case we make NS_MAIN 'MAIN'
+ $this->assertSameSubject( NS_USER, NS_USER );
+ $this->assertSameSubject( NS_USER, 2 );
+ $this->assertSameSubject( NS_USER_TALK, NS_USER_TALK );
+ $this->assertSameSubject( NS_SPECIAL, NS_SPECIAL );
+ $this->assertSameSubject( NS_MAIN, NS_TALK );
+ $this->assertSameSubject( NS_USER, NS_USER_TALK );
+
+ $this->assertDifferentSubject( NS_PROJECT, NS_TEMPLATE );
+ $this->assertDifferentSubject( NS_SPECIAL, NS_MAIN );
+ }
+
+ /**
+ * @covers NamespaceInfo::subjectEquals
+ */
+ public function testSpecialAndMediaAreDifferentSubjects() {
+ $this->assertDifferentSubject(
+ NS_MEDIA, NS_SPECIAL,
+ "NS_MEDIA and NS_SPECIAL are different subject namespaces"
+ );
+ $this->assertDifferentSubject(
+ NS_SPECIAL, NS_MEDIA,
+ "NS_SPECIAL and NS_MEDIA are different subject namespaces"
+ );
+ }
+
+ public function provideHasTalkNamespace() {
+ return [
+ [ NS_MEDIA, false ],
+ [ NS_SPECIAL, false ],
+
+ [ NS_MAIN, true ],
+ [ NS_TALK, true ],
+ [ NS_USER, true ],
+ [ NS_USER_TALK, true ],
+
+ [ 100, true ],
+ [ 101, true ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideHasTalkNamespace
+ * @covers NamespaceInfo::hasTalkNamespace
+ *
+ * @param int $index
+ * @param bool $expected
+ */
+ public function testHasTalkNamespace( $index, $expected ) {
+ $actual = $this->obj->hasTalkNamespace( $index );
+ $this->assertSame( $actual, $expected, "NS $index" );
+ }
+
+ /**
+ * @dataProvider provideHasTalkNamespace
+ * @covers MWNamespace::canTalk
+ *
+ * @param int $index
+ * @param bool $expected
+ */
+ public function testCanTalk( $index, $expected ) {
+ $this->hideDeprecated( 'MWNamespace::canTalk' );
+ $actual = MWNamespace::canTalk( $index );
+ $this->assertSame( $actual, $expected, "NS $index" );
+ }
+
+ private function assertIsContent( $ns ) {
+ $this->assertTrue( $this->obj->isContent( $ns ) );
+ }
+
+ private function assertIsNotContent( $ns ) {
+ $this->assertFalse( $this->obj->isContent( $ns ) );
+ }
+
+ /**
+ * @covers NamespaceInfo::isContent
+ */
+ public function testIsContent() {
+ // NS_MAIN is a content namespace per DefaultSettings.php
+ // and per function definition.
+
+ $this->assertIsContent( NS_MAIN );
+
+ // Other namespaces which are not expected to be content
+
+ $this->assertIsNotContent( NS_MEDIA );
+ $this->assertIsNotContent( NS_SPECIAL );
+ $this->assertIsNotContent( NS_TALK );
+ $this->assertIsNotContent( NS_USER );
+ $this->assertIsNotContent( NS_CATEGORY );
+ $this->assertIsNotContent( 100 );
+ }
+
+ /**
+ * Similar to testIsContent() but alters the $wgContentNamespaces
+ * global variable.
+ * @covers NamespaceInfo::isContent
+ */
+ public function testIsContentAdvanced() {
+ global $wgContentNamespaces;
+
+ // Test that user defined namespace #252 is not content
+ $this->assertIsNotContent( 252 );
+
+ // Bless namespace # 252 as a content namespace
+ $wgContentNamespaces[] = 252;
+
+ $this->assertIsContent( 252 );
+
+ // Makes sure NS_MAIN was not impacted
+ $this->assertIsContent( NS_MAIN );
+ }
+
+ private function assertIsWatchable( $ns ) {
+ $this->assertTrue( $this->obj->isWatchable( $ns ) );
+ }
+
+ private function assertIsNotWatchable( $ns ) {
+ $this->assertFalse( $this->obj->isWatchable( $ns ) );
+ }
+
+ /**
+ * @covers NamespaceInfo::isWatchable
+ */
+ public function testIsWatchable() {
+ // Specials namespaces are not watchable
+ $this->assertIsNotWatchable( NS_MEDIA );
+ $this->assertIsNotWatchable( NS_SPECIAL );
+
+ // Core defined namespaces are watchables
+ $this->assertIsWatchable( NS_MAIN );
+ $this->assertIsWatchable( NS_TALK );
+
+ // Additional, user defined namespaces are watchables
+ $this->assertIsWatchable( 100 );
+ $this->assertIsWatchable( 101 );
+ }
+
+ private function assertHasSubpages( $ns ) {
+ $this->assertTrue( $this->obj->hasSubpages( $ns ) );
+ }
+
+ private function assertHasNotSubpages( $ns ) {
+ $this->assertFalse( $this->obj->hasSubpages( $ns ) );
+ }
+
+ /**
+ * @covers NamespaceInfo::hasSubpages
+ */
+ public function testHasSubpages() {
+ global $wgNamespacesWithSubpages;
+
+ // Special namespaces:
+ $this->assertHasNotSubpages( NS_MEDIA );
+ $this->assertHasNotSubpages( NS_SPECIAL );
+
+ // Namespaces without subpages
+ $this->assertHasNotSubpages( NS_MAIN );
+
+ $wgNamespacesWithSubpages[NS_MAIN] = true;
+ $this->assertHasSubpages( NS_MAIN );
+
+ $wgNamespacesWithSubpages[NS_MAIN] = false;
+ $this->assertHasNotSubpages( NS_MAIN );
+
+ // Some namespaces with subpages
+ $this->assertHasSubpages( NS_TALK );
+ $this->assertHasSubpages( NS_USER );
+ $this->assertHasSubpages( NS_USER_TALK );
+ }
+
+ /**
+ * @covers NamespaceInfo::getContentNamespaces
+ */
+ public function testGetContentNamespaces() {
+ global $wgContentNamespaces;
+
+ $this->assertEquals(
+ [ NS_MAIN ],
+ $this->obj->getContentNamespaces(),
+ '$wgContentNamespaces is an array with only NS_MAIN by default'
+ );
+
+ # test !is_array( $wgcontentNamespaces )
+ $wgContentNamespaces = '';
+ $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+ $wgContentNamespaces = false;
+ $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+ $wgContentNamespaces = null;
+ $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+ $wgContentNamespaces = 5;
+ $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+ # test $wgContentNamespaces === []
+ $wgContentNamespaces = [];
+ $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+ # test !in_array( NS_MAIN, $wgContentNamespaces )
+ $wgContentNamespaces = [ NS_USER, NS_CATEGORY ];
+ $this->assertEquals(
+ [ NS_MAIN, NS_USER, NS_CATEGORY ],
+ $this->obj->getContentNamespaces(),
+ 'NS_MAIN is forced in $wgContentNamespaces even if unwanted'
+ );
+
+ # test other cases, return $wgcontentNamespaces as is
+ $wgContentNamespaces = [ NS_MAIN ];
+ $this->assertEquals(
+ [ NS_MAIN ],
+ $this->obj->getContentNamespaces()
+ );
+
+ $wgContentNamespaces = [ NS_MAIN, NS_USER, NS_CATEGORY ];
+ $this->assertEquals(
+ [ NS_MAIN, NS_USER, NS_CATEGORY ],
+ $this->obj->getContentNamespaces()
+ );
+ }
+
+ /**
+ * @covers NamespaceInfo::getSubjectNamespaces
+ */
+ public function testGetSubjectNamespaces() {
+ $subjectsNS = $this->obj->getSubjectNamespaces();
+ $this->assertContains( NS_MAIN, $subjectsNS,
+ "Talk namespaces should have NS_MAIN" );
+ $this->assertNotContains( NS_TALK, $subjectsNS,
+ "Talk namespaces should have NS_TALK" );
+
+ $this->assertNotContains( NS_MEDIA, $subjectsNS,
+ "Talk namespaces should not have NS_MEDIA" );
+ $this->assertNotContains( NS_SPECIAL, $subjectsNS,
+ "Talk namespaces should not have NS_SPECIAL" );
+ }
+
+ /**
+ * @covers NamespaceInfo::getTalkNamespaces
+ */
+ public function testGetTalkNamespaces() {
+ $talkNS = $this->obj->getTalkNamespaces();
+ $this->assertContains( NS_TALK, $talkNS,
+ "Subject namespaces should have NS_TALK" );
+ $this->assertNotContains( NS_MAIN, $talkNS,
+ "Subject namespaces should not have NS_MAIN" );
+
+ $this->assertNotContains( NS_MEDIA, $talkNS,
+ "Subject namespaces should not have NS_MEDIA" );
+ $this->assertNotContains( NS_SPECIAL, $talkNS,
+ "Subject namespaces should not have NS_SPECIAL" );
+ }
+
+ private function assertIsCapitalized( $ns ) {
+ $this->assertTrue( $this->obj->isCapitalized( $ns ) );
+ }
+
+ private function assertIsNotCapitalized( $ns ) {
+ $this->assertFalse( $this->obj->isCapitalized( $ns ) );
+ }
+
+ /**
+ * Some namespaces are always capitalized per code definition
+ * in NamespaceInfo::$alwaysCapitalizedNamespaces
+ * @covers NamespaceInfo::isCapitalized
+ */
+ public function testIsCapitalizedHardcodedAssertions() {
+ // NS_MEDIA and NS_FILE are treated the same
+ $this->assertEquals(
+ $this->obj->isCapitalized( NS_MEDIA ),
+ $this->obj->isCapitalized( NS_FILE ),
+ 'NS_MEDIA and NS_FILE have same capitalization rendering'
+ );
+
+ // Boths are capitalized by default
+ $this->assertIsCapitalized( NS_MEDIA );
+ $this->assertIsCapitalized( NS_FILE );
+
+ // Always capitalized namespaces
+ // @see NamespaceInfo::$alwaysCapitalizedNamespaces
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+ }
+
+ /**
+ * Follows up for testIsCapitalizedHardcodedAssertions() but alter the
+ * global $wgCapitalLink setting to have extended coverage.
+ *
+ * NamespaceInfo::isCapitalized() rely on two global settings:
+ * $wgCapitalLinkOverrides = []; by default
+ * $wgCapitalLinks = true; by default
+ * This function test $wgCapitalLinks
+ *
+ * Global setting correctness is tested against the NS_PROJECT and
+ * NS_PROJECT_TALK namespaces since they are not hardcoded nor specials
+ * @covers NamespaceInfo::isCapitalized
+ */
+ public function testIsCapitalizedWithWgCapitalLinks() {
+ $this->assertIsCapitalized( NS_PROJECT );
+ $this->assertIsCapitalized( NS_PROJECT_TALK );
+
+ $this->setMwGlobals( 'wgCapitalLinks', false );
+
+ // hardcoded namespaces (see above function) are still capitalized:
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+ // setting is correctly applied
+ $this->assertIsNotCapitalized( NS_PROJECT );
+ $this->assertIsNotCapitalized( NS_PROJECT_TALK );
+ }
+
+ /**
+ * Counter part for NamespaceInfo::testIsCapitalizedWithWgCapitalLinks() now
+ * testing the $wgCapitalLinkOverrides global.
+ *
+ * @todo split groups of assertions in autonomous testing functions
+ * @covers NamespaceInfo::isCapitalized
+ */
+ public function testIsCapitalizedWithWgCapitalLinkOverrides() {
+ global $wgCapitalLinkOverrides;
+
+ // Test default settings
+ $this->assertIsCapitalized( NS_PROJECT );
+ $this->assertIsCapitalized( NS_PROJECT_TALK );
+
+ // hardcoded namespaces (see above function) are capitalized:
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+ // Hardcoded namespaces remains capitalized
+ $wgCapitalLinkOverrides[NS_SPECIAL] = false;
+ $wgCapitalLinkOverrides[NS_USER] = false;
+ $wgCapitalLinkOverrides[NS_MEDIAWIKI] = false;
+
+ $this->assertIsCapitalized( NS_SPECIAL );
+ $this->assertIsCapitalized( NS_USER );
+ $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+ $wgCapitalLinkOverrides[NS_PROJECT] = false;
+ $this->assertIsNotCapitalized( NS_PROJECT );
+
+ $wgCapitalLinkOverrides[NS_PROJECT] = true;
+ $this->assertIsCapitalized( NS_PROJECT );
+
+ unset( $wgCapitalLinkOverrides[NS_PROJECT] );
+ $this->assertIsCapitalized( NS_PROJECT );
+ }
+
+ /**
+ * @covers NamespaceInfo::hasGenderDistinction
+ */
+ public function testHasGenderDistinction() {
+ // Namespaces with gender distinctions
+ $this->assertTrue( $this->obj->hasGenderDistinction( NS_USER ) );
+ $this->assertTrue( $this->obj->hasGenderDistinction( NS_USER_TALK ) );
+
+ // Other ones, "genderless"
+ $this->assertFalse( $this->obj->hasGenderDistinction( NS_MEDIA ) );
+ $this->assertFalse( $this->obj->hasGenderDistinction( NS_SPECIAL ) );
+ $this->assertFalse( $this->obj->hasGenderDistinction( NS_MAIN ) );
+ $this->assertFalse( $this->obj->hasGenderDistinction( NS_TALK ) );
+ }
+
+ /**
+ * @covers NamespaceInfo::isNonincludable
+ */
+ public function testIsNonincludable() {
+ global $wgNonincludableNamespaces;
+
+ $wgNonincludableNamespaces = [ NS_USER ];
+
+ $this->assertTrue( $this->obj->isNonincludable( NS_USER ) );
+ $this->assertFalse( $this->obj->isNonincludable( NS_TEMPLATE ) );
+ }
+
+ private function assertSameSubject( $ns1, $ns2, $msg = '' ) {
+ $this->assertTrue( $this->obj->subjectEquals( $ns1, $ns2 ), $msg );
+ }
+
+ private function assertDifferentSubject( $ns1, $ns2, $msg = '' ) {
+ $this->assertFalse( $this->obj->subjectEquals( $ns1, $ns2 ), $msg );
+ }
+
+ public function provideGetCategoryLinkType() {
+ return [
+ [ NS_MAIN, 'page' ],
+ [ NS_TALK, 'page' ],
+ [ NS_USER, 'page' ],
+ [ NS_USER_TALK, 'page' ],
+
+ [ NS_FILE, 'file' ],
+ [ NS_FILE_TALK, 'page' ],
+
+ [ NS_CATEGORY, 'subcat' ],
+ [ NS_CATEGORY_TALK, 'page' ],
+
+ [ 100, 'page' ],
+ [ 101, 'page' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetCategoryLinkType
+ * @covers NamespaceInfo::getCategoryLinkType
+ *
+ * @param int $index
+ * @param string $expected
+ */
+ public function testGetCategoryLinkType( $index, $expected ) {
+ $actual = $this->obj->getCategoryLinkType( $index );
+ $this->assertSame( $expected, $actual, "NS $index" );
+ }
+}
$this->assertNodeStart( "contributor" );
$this->skipWhitespace();
- $this->assertTextNode( "ip", false );
+ $this->assertTextNode( "username", false );
+ $this->assertTextNode( "id", false );
$this->assertNodeEnd( "contributor" );
$this->skipWhitespace();
) {
$status = $page->doEditContent(
ContentHandler::makeContent( $text, $page->getTitle(), $model ),
- $summary
+ $summary, 0, false, $this->getTestUser()->getUser()
);
if ( $status->isGood() ) {
$content = $header;
$iterations = intval( $iterations );
+ $username = $this->getTestUser()->getUser()->getName();
+ $userid = $this->getTestUser()->getUser()->getId();
for ( $i = 0; $i < $iterations; $i++ ) {
$page1 = ' <page>
<title>BackupDumperTestP1</title>
<id>' . ( $this->revId1_1 + $i * self::$numOfRevs ) . '</id>
<timestamp>2012-04-01T16:46:05Z</timestamp>
<contributor>
- <ip>127.0.0.1</ip>
+ <username>' . $username . '</username>
+ <id>' . $userid . '</id>
</contributor>
<comment>BackupDumperTestP1Summary1</comment>
<model>wikitext</model>
<id>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</id>
<timestamp>2012-04-01T16:46:05Z</timestamp>
<contributor>
- <ip>127.0.0.1</ip>
+ <username>' . $username . '</username>
+ <id>' . $userid . '</id>
</contributor>
<comment>BackupDumperTestP2Summary1</comment>
<model>wikitext</model>
<parentid>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</parentid>
<timestamp>2012-04-01T16:46:05Z</timestamp>
<contributor>
- <ip>127.0.0.1</ip>
+ <username>' . $username . '</username>
+ <id>' . $userid . '</id>
</contributor>
<comment>BackupDumperTestP2Summary2</comment>
<model>wikitext</model>
<parentid>' . ( $this->revId2_2 + $i * self::$numOfRevs ) . '</parentid>
<timestamp>2012-04-01T16:46:05Z</timestamp>
<contributor>
- <ip>127.0.0.1</ip>
+ <username>' . $username . '</username>
+ <id>' . $userid . '</id>
</contributor>
<comment>BackupDumperTestP2Summary3</comment>
<model>wikitext</model>
<parentid>' . ( $this->revId2_3 + $i * self::$numOfRevs ) . '</parentid>
<timestamp>2012-04-01T16:46:05Z</timestamp>
<contributor>
- <ip>127.0.0.1</ip>
+ <username>' . $username . '</username>
+ <id>' . $userid . '</id>
</contributor>
<comment>BackupDumperTestP2Summary4 extra</comment>
<model>wikitext</model>
<id>' . ( $this->revId4_1 + $i * self::$numOfRevs ) . '</id>
<timestamp>2012-04-01T16:46:05Z</timestamp>
<contributor>
- <ip>127.0.0.1</ip>
+ <username>' . $username . '</username>
+ <id>' . $userid . '</id>
</contributor>
<comment>Talk BackupDumperTestP1 Summary1</comment>
<model>BackupTextPassTestModel</model>